Spaces:
Runtime error
Runtime error
Deploy Waterleaf challenge app
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +10 -0
- .env.example +13 -0
- .gitattributes +1 -0
- .gitignore +16 -0
- Dockerfile +28 -0
- LICENSE +22 -0
- README.md +261 -5
- app.py +6 -0
- assets/sample-lavender.png +3 -0
- docs/architecture.md +37 -0
- docs/demo-script.md +14 -0
- docs/field-notes.md +48 -0
- docs/social-post.md +16 -0
- docs/submission-checklist.md +16 -0
- evaluation/README.md +16 -0
- evaluation/manifest.example.csv +3 -0
- modal_app.py +116 -0
- pyproject.toml +43 -0
- scripts/evaluate.py +57 -0
- scripts/smoke_modal.py +30 -0
- tests/test_adapters.py +325 -0
- tests/test_application.py +189 -0
- tests/test_calendar.py +90 -0
- tests/test_evaluation.py +33 -0
- tests/test_identification.py +58 -0
- tests/test_images.py +32 -0
- tests/test_rate_limit.py +17 -0
- tests/test_runtime.py +29 -0
- tests/test_scheduling.py +91 -0
- tests/test_storage.py +83 -0
- tests/test_ui.py +129 -0
- uv.lock +0 -0
- waterleaf/__init__.py +2 -0
- waterleaf/application.py +185 -0
- waterleaf/calendar.py +113 -0
- waterleaf/evaluation.py +43 -0
- waterleaf/images.py +41 -0
- waterleaf/models.py +138 -0
- waterleaf/rate_limit.py +26 -0
- waterleaf/runtime.py +45 -0
- waterleaf/scheduling.py +79 -0
- waterleaf/services/__init__.py +2 -0
- waterleaf/services/demo.py +77 -0
- waterleaf/services/gbif.py +143 -0
- waterleaf/services/identification.py +59 -0
- waterleaf/services/llama_cpp.py +167 -0
- waterleaf/services/open_meteo.py +59 -0
- waterleaf/services/perenual.py +119 -0
- waterleaf/settings.py +49 -0
- waterleaf/storage.py +221 -0
.dockerignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
.pytest_cache
|
| 4 |
+
.ruff_cache
|
| 5 |
+
.env
|
| 6 |
+
data
|
| 7 |
+
tests
|
| 8 |
+
evaluation
|
| 9 |
+
docs
|
| 10 |
+
|
.env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space runtime
|
| 2 |
+
WATERLEAF_DATA_DIR=data
|
| 3 |
+
PUBLIC_BASE_URL=http://localhost:7860
|
| 4 |
+
WATERLEAF_LOCAL_USER=local-gardener
|
| 5 |
+
|
| 6 |
+
# Optional care-data provider
|
| 7 |
+
PERENUAL_API_KEY=
|
| 8 |
+
|
| 9 |
+
# Modal proxy-authenticated llama.cpp endpoint
|
| 10 |
+
MODAL_ENDPOINT=
|
| 11 |
+
MODAL_KEY=
|
| 12 |
+
MODAL_SECRET=
|
| 13 |
+
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/sample-lavender.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
!.env.example
|
| 4 |
+
.venv/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.ruff_cache/
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
*.egg-info/
|
| 14 |
+
data/
|
| 15 |
+
.superpowers/
|
| 16 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
UV_COMPILE_BYTECODE=1 \
|
| 6 |
+
UV_LINK_MODE=copy \
|
| 7 |
+
WATERLEAF_DATA_DIR=/data
|
| 8 |
+
|
| 9 |
+
RUN pip install --no-cache-dir uv==0.11.8 \
|
| 10 |
+
&& useradd --create-home --uid 1000 user
|
| 11 |
+
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
COPY --chown=user:user pyproject.toml uv.lock README.md ./
|
| 14 |
+
COPY --chown=user:user waterleaf ./waterleaf
|
| 15 |
+
COPY --chown=user:user assets ./assets
|
| 16 |
+
COPY --chown=user:user app.py ./
|
| 17 |
+
|
| 18 |
+
RUN uv sync --frozen --no-dev
|
| 19 |
+
|
| 20 |
+
USER user
|
| 21 |
+
ENV PATH="/app/.venv/bin:${PATH}"
|
| 22 |
+
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
| 25 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=3)"
|
| 26 |
+
|
| 27 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 28 |
+
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Hakan Karaoguz
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
README.md
CHANGED
|
@@ -1,10 +1,266 @@
|
|
| 1 |
---
|
| 2 |
title: Waterleaf
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Waterleaf
|
| 3 |
+
emoji: 🌿
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
hf_oauth: true
|
| 9 |
+
hf_oauth_expiration_minutes: 43200
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Waterleaf
|
| 14 |
+
|
| 15 |
+
Waterleaf identifies an outdoor garden plant from one to three photographs,
|
| 16 |
+
grounds the result in a plant taxonomy database, builds an editable
|
| 17 |
+
weather-aware watering plan, and exports one 30-day calendar for the garden.
|
| 18 |
+
|
| 19 |
+
Built by Hakan Karaoguz (`hkaraoguz`) for the 2026 Hugging Face Build Small
|
| 20 |
+
Hackathon.
|
| 21 |
+
|
| 22 |
+
## Workflow
|
| 23 |
+
|
| 24 |
+
1. Upload or capture one to three photographs of one plant.
|
| 25 |
+
2. Gemma 4 extracts visible traits and proposes likely names.
|
| 26 |
+
3. GBIF resolves those names to valid plant species.
|
| 27 |
+
4. Gemma reranks only the valid records.
|
| 28 |
+
5. Confirm or replace the species through autocomplete.
|
| 29 |
+
6. Preview weather-adjusted dates and edit them.
|
| 30 |
+
7. Save plants and export one whole-garden ICS file.
|
| 31 |
+
|
| 32 |
+
## Architecture
|
| 33 |
+
|
| 34 |
+
- **UI and web:** Gradio Blocks mounted in FastAPI
|
| 35 |
+
- **Authentication:** Hugging Face OAuth
|
| 36 |
+
- **Persistence:** SQLite and normalized JPEGs on an attached HF Storage Bucket
|
| 37 |
+
- **Vision model:** `ggml-org/gemma-4-26B-A4B-it-GGUF`
|
| 38 |
+
- **Runtime:** llama.cpp `server-cuda13-b9445` on a Modal L4
|
| 39 |
+
- **Taxonomy:** GBIF Species API
|
| 40 |
+
- **Care data:** Perenual, with persistent caching and manual interval fallback
|
| 41 |
+
- **Weather:** Open-Meteo geocoding and 16-day forecast
|
| 42 |
+
- **Calendar:** RFC 5545-compatible ICS with stable UIDs, alarms, profile URLs,
|
| 43 |
+
and image attachments
|
| 44 |
+
|
| 45 |
+
See [docs/architecture.md](docs/architecture.md) for the data flow and privacy
|
| 46 |
+
boundaries.
|
| 47 |
+
|
| 48 |
+
## Local Development
|
| 49 |
+
|
| 50 |
+
Python 3.11-3.13 and `uv` are supported.
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
uv sync
|
| 54 |
+
uv run uvicorn app:app --host 0.0.0.0 --port 7860
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Open `http://localhost:7860`. Without `MODAL_ENDPOINT`, Waterleaf uses a
|
| 58 |
+
deterministic lavender demo identifier. Local persistence uses the
|
| 59 |
+
`local-gardener` identity and `data/` directory.
|
| 60 |
+
|
| 61 |
+
Run checks:
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
uv run pytest
|
| 65 |
+
uv run ruff check .
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## Modal Deployment
|
| 69 |
+
|
| 70 |
+
### 1. Authenticate the Modal CLI
|
| 71 |
+
|
| 72 |
+
Install the deploy dependency and connect the local CLI to the Modal workspace:
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
uv sync --group deploy
|
| 76 |
+
uv run --group deploy modal setup
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### 2. Deploy llama.cpp
|
| 80 |
+
|
| 81 |
+
Deploy the protected GPU service:
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
uv run --group deploy modal deploy modal_app.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
The Modal service:
|
| 88 |
+
|
| 89 |
+
- uses the pinned `ghcr.io/ggml-org/llama.cpp:server-cuda13-b9445` image;
|
| 90 |
+
- starts `Gemma 4 26B-A4B Q4_K_M` with automatic multimodal projector download;
|
| 91 |
+
- uses an 8K context, full GPU offload, Flash Attention, Q8 KV cache, and one
|
| 92 |
+
parallel slot;
|
| 93 |
+
- uses a bounded 256-token thinking pass for database-candidate reranking while
|
| 94 |
+
keeping initial visual extraction non-thinking and schema-constrained;
|
| 95 |
+
- caches Hugging Face artifacts in a Modal Volume;
|
| 96 |
+
- requires Modal proxy-auth headers.
|
| 97 |
+
|
| 98 |
+
The command prints the `modal.run` URL. Save it as `MODAL_ENDPOINT`.
|
| 99 |
+
|
| 100 |
+
For the live demo and judging window, keep one container warm:
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
MODAL_MIN_CONTAINERS=1 uv run --group deploy modal deploy modal_app.py
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Return to zero warm containers after judging to stop idle GPU spend:
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
MODAL_MIN_CONTAINERS=0 uv run --group deploy modal deploy modal_app.py
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### 3. Create proxy credentials
|
| 113 |
+
|
| 114 |
+
In Modal Workspace Settings, create a **Web endpoint proxy auth token**. Save
|
| 115 |
+
the token ID as `MODAL_KEY` and token secret as `MODAL_SECRET`. These are not
|
| 116 |
+
the same credentials used by `modal setup`.
|
| 117 |
+
|
| 118 |
+
Test the endpoint before configuring the Space:
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
MODAL_ENDPOINT=https://...modal.run \
|
| 122 |
+
MODAL_KEY=wk-... \
|
| 123 |
+
MODAL_SECRET=ws-... \
|
| 124 |
+
uv run python scripts/smoke_modal.py assets/sample-lavender.png
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
If a proxy token has not been created yet, a controlled one-time smoke test can
|
| 128 |
+
temporarily publish the endpoint:
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
MODAL_PROXY_AUTH=0 MODAL_MIN_CONTAINERS=1 \
|
| 132 |
+
uv run --group deploy modal deploy modal_app.py
|
| 133 |
+
MODAL_ENDPOINT=https://...modal.run \
|
| 134 |
+
uv run python scripts/smoke_modal.py assets/sample-lavender.png
|
| 135 |
+
MODAL_PROXY_AUTH=1 MODAL_MIN_CONTAINERS=0 \
|
| 136 |
+
uv run --group deploy modal deploy modal_app.py
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The middle deployment is unauthenticated and should exist only for the smoke
|
| 140 |
+
test. Always run the final restore command immediately afterward.
|
| 141 |
+
|
| 142 |
+
## Space Configuration
|
| 143 |
+
|
| 144 |
+
### 1. Create the Space
|
| 145 |
+
|
| 146 |
+
Create `build-small-hackathon/waterleaf` in the Hugging Face UI with:
|
| 147 |
+
|
| 148 |
+
- **SDK:** Docker
|
| 149 |
+
- **Visibility:** Public
|
| 150 |
+
- **License:** MIT
|
| 151 |
+
|
| 152 |
+
If the hackathon organization does not allow direct creation, create
|
| 153 |
+
`hkaraoguz/waterleaf` first and transfer or duplicate it into the requested
|
| 154 |
+
hackathon namespace.
|
| 155 |
+
|
| 156 |
+
The root README metadata already enables Docker on port `7860` and HF OAuth.
|
| 157 |
+
|
| 158 |
+
### 2. Push this repository
|
| 159 |
+
|
| 160 |
+
Add the Space as a Git remote and push the feature branch:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
git remote add hf https://huggingface.co/spaces/build-small-hackathon/waterleaf
|
| 164 |
+
git push hf feat/waterleaf:main
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
Use an HF user access token as the Git password when prompted. Do not commit
|
| 168 |
+
that token or any deployment secret.
|
| 169 |
+
|
| 170 |
+
### 3. Attach persistent storage
|
| 171 |
+
|
| 172 |
+
In **Space Settings → Storage Buckets**:
|
| 173 |
+
|
| 174 |
+
1. Create or select a bucket for Waterleaf.
|
| 175 |
+
2. Attach it read-write.
|
| 176 |
+
3. Set the mount path to `/data`.
|
| 177 |
+
|
| 178 |
+
The Docker image already sets `WATERLEAF_DATA_DIR=/data`. Without this mount,
|
| 179 |
+
saved gardens and images disappear when the Space restarts.
|
| 180 |
+
|
| 181 |
+
### 4. Configure secrets and variables
|
| 182 |
+
|
| 183 |
+
In **Space Settings → Variables and secrets**, add:
|
| 184 |
+
|
| 185 |
+
| Name | Type | Required | Purpose |
|
| 186 |
+
| --- | --- | --- | --- |
|
| 187 |
+
| `MODAL_ENDPOINT` | Secret | Production | Protected llama.cpp base URL |
|
| 188 |
+
| `MODAL_KEY` | Secret | Production | Modal proxy token ID |
|
| 189 |
+
| `MODAL_SECRET` | Secret | Production | Modal proxy token secret |
|
| 190 |
+
| `PERENUAL_API_KEY` | Secret | Optional | Plant care benchmark lookup |
|
| 191 |
+
| `PUBLIC_BASE_URL` | Variable | Optional | Override the derived Space URL |
|
| 192 |
+
| `WATERLEAF_DATA_DIR` | Variable | No | Defaults to `/data` in Docker |
|
| 193 |
+
|
| 194 |
+
Use only the base Modal URL for `MODAL_ENDPOINT`; do not append
|
| 195 |
+
`/v1/chat/completions`.
|
| 196 |
+
|
| 197 |
+
### 5. Rebuild and verify
|
| 198 |
+
|
| 199 |
+
Trigger **Factory reboot** after attaching storage or changing secrets. Then
|
| 200 |
+
verify:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
curl --fail https://build-small-hackathon-waterleaf.hf.space/health
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
Expected response:
|
| 207 |
+
|
| 208 |
+
```json
|
| 209 |
+
{"status":"ok"}
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
Open the Space directly, not only inside the Hub iframe, and check:
|
| 213 |
+
|
| 214 |
+
1. **Sign in with Hugging Face** completes successfully.
|
| 215 |
+
2. A guest can preview one identification.
|
| 216 |
+
3. A signed-in user can save a plant and see it after a factory restart.
|
| 217 |
+
4. The generated ICS downloads and its public plant/image links open.
|
| 218 |
+
|
| 219 |
+
Guests may run one temporary identification preview. Login is required to
|
| 220 |
+
save, delete, or export plants.
|
| 221 |
+
|
| 222 |
+
## Evaluation
|
| 223 |
+
|
| 224 |
+
Populate `evaluation/manifest.csv` with at least 20 consented real-garden
|
| 225 |
+
examples and run:
|
| 226 |
+
|
| 227 |
+
```bash
|
| 228 |
+
MODAL_ENDPOINT=... MODAL_KEY=... MODAL_SECRET=... \
|
| 229 |
+
uv run python scripts/evaluate.py evaluation/manifest.csv
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
The report includes species top-1/top-3 accuracy, genus top-1 accuracy,
|
| 233 |
+
per-case predictions, and latency. A live one-to-three-photo smoke test is
|
| 234 |
+
available at `scripts/smoke_modal.py`.
|
| 235 |
+
|
| 236 |
+
## Privacy and Limitations
|
| 237 |
+
|
| 238 |
+
- Images are resized, converted to JPEG, and stripped of EXIF.
|
| 239 |
+
- Stored coordinates are rounded and never exposed on public plant pages.
|
| 240 |
+
- Public pages use opaque slugs but are intentionally public for calendar use.
|
| 241 |
+
- Plant identification and watering dates are suggestions, not horticultural
|
| 242 |
+
guarantees.
|
| 243 |
+
- Dates after the 16-day forecast are labeled seasonal estimates.
|
| 244 |
+
- ICS `ATTACH` support varies by calendar client; every event also includes a
|
| 245 |
+
portable public profile link.
|
| 246 |
+
- Perenual can be omitted; users must provide a manual interval when no care
|
| 247 |
+
benchmark is available.
|
| 248 |
+
|
| 249 |
+
## Submission Materials
|
| 250 |
+
|
| 251 |
+
- [Field Notes draft](docs/field-notes.md)
|
| 252 |
+
- [Demo script](docs/demo-script.md)
|
| 253 |
+
- [Social post draft](docs/social-post.md)
|
| 254 |
+
- [Submission checklist](docs/submission-checklist.md)
|
| 255 |
+
|
| 256 |
+
Target quests: Backyard AI, Llama Champion, Modal-powered, and Field Notes.
|
| 257 |
+
Waterleaf does not claim Off the Grid because inference and weather data are
|
| 258 |
+
cloud-hosted.
|
| 259 |
+
|
| 260 |
+
## Credits
|
| 261 |
+
|
| 262 |
+
- [Gemma 4](https://huggingface.co/google/gemma-4-26B-A4B-it)
|
| 263 |
+
- [llama.cpp](https://github.com/ggml-org/llama.cpp)
|
| 264 |
+
- [GBIF](https://www.gbif.org/developer/species)
|
| 265 |
+
- [Perenual](https://perenual.com/docs/api)
|
| 266 |
+
- [Open-Meteo](https://open-meteo.com/)
|
app.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from waterleaf.runtime import build_application
|
| 2 |
+
from waterleaf.web import create_web_app
|
| 3 |
+
|
| 4 |
+
application = build_application()
|
| 5 |
+
app = create_web_app(application)
|
| 6 |
+
|
assets/sample-lavender.png
ADDED
|
Git LFS Details
|
docs/architecture.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Waterleaf Architecture
|
| 2 |
+
|
| 3 |
+
```mermaid
|
| 4 |
+
flowchart LR
|
| 5 |
+
U[Gardener] --> G[Gradio Blocks]
|
| 6 |
+
G --> A[FastAPI application]
|
| 7 |
+
A --> O[Hugging Face OAuth]
|
| 8 |
+
A --> S[(SQLite + images\nHF Storage Bucket)]
|
| 9 |
+
A --> M[Modal proxy-auth endpoint]
|
| 10 |
+
M --> L[llama.cpp\nGemma 4 26B-A4B GGUF]
|
| 11 |
+
A --> B[GBIF taxonomy]
|
| 12 |
+
A --> P[Perenual care data]
|
| 13 |
+
A --> W[Open-Meteo]
|
| 14 |
+
A --> I[Whole-garden ICS]
|
| 15 |
+
I --> C[Apple / Google / Outlook]
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Identification
|
| 19 |
+
|
| 20 |
+
1. Gemma extracts visible traits, candidate names, container status, and size.
|
| 21 |
+
2. GBIF resolves names to valid `Plantae` species records.
|
| 22 |
+
3. Gemma reranks only those records. Unknown keys are discarded.
|
| 23 |
+
4. The user confirms a candidate or replaces it through taxonomy autocomplete.
|
| 24 |
+
|
| 25 |
+
## Scheduling
|
| 26 |
+
|
| 27 |
+
Perenual provides a baseline interval. Waterleaf shortens the interval for
|
| 28 |
+
containers, applies bounded rain and heat adjustments to the 16-day forecast,
|
| 29 |
+
and labels later dates as seasonal estimates. The language model never chooses
|
| 30 |
+
watering dates.
|
| 31 |
+
|
| 32 |
+
## Privacy
|
| 33 |
+
|
| 34 |
+
HF username is the private ownership key. Coordinates are rounded before
|
| 35 |
+
storage. Public plant profiles use opaque slugs and omit account and location
|
| 36 |
+
data. Uploaded images are resized, converted to JPEG, and stripped of EXIF.
|
| 37 |
+
|
docs/demo-script.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Waterleaf Demo Script
|
| 2 |
+
|
| 3 |
+
Target length: 60-90 seconds.
|
| 4 |
+
|
| 5 |
+
1. Open the garden dashboard and show the existing sample plant.
|
| 6 |
+
2. Add one to three photographs of a real outdoor plant.
|
| 7 |
+
3. Run analysis and point out the visible traits and three GBIF-backed matches.
|
| 8 |
+
4. Replace or confirm the species using autocomplete.
|
| 9 |
+
5. Enter the plant nickname, city, and preferred watering time.
|
| 10 |
+
6. Show the inferred container status and editable 30-day dates.
|
| 11 |
+
7. Save the plant and return to the dashboard.
|
| 12 |
+
8. Generate the whole-garden ICS file.
|
| 13 |
+
9. Open one imported calendar event and follow the plant profile/image link.
|
| 14 |
+
10. End on the architecture diagram: Gradio Space, Modal, llama.cpp, Gemma 4.
|
docs/field-notes.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes: Building Waterleaf with Gemma 4 and llama.cpp
|
| 2 |
+
|
| 3 |
+
## The person and the problem
|
| 4 |
+
|
| 5 |
+
Waterleaf is being built for a gardener who recognizes many plants by sight but
|
| 6 |
+
does not want to maintain a spreadsheet of watering dates. The useful outcome
|
| 7 |
+
is not another plant-identification answer. It is a calendar that can be acted
|
| 8 |
+
on and corrected.
|
| 9 |
+
|
| 10 |
+
## Why a grounded pipeline
|
| 11 |
+
|
| 12 |
+
A vision-language model can describe leaf shape, flower form, growth habit, and
|
| 13 |
+
visible planting context. It should not be the database of record. Waterleaf
|
| 14 |
+
therefore asks Gemma 4 for observations and candidate names, resolves those
|
| 15 |
+
names through GBIF, and allows Gemma to rerank only valid taxonomy records.
|
| 16 |
+
The gardener makes the final choice.
|
| 17 |
+
|
| 18 |
+
## Running the large model small
|
| 19 |
+
|
| 20 |
+
The model is Gemma 4 26B-A4B, a mixture-of-experts model with roughly 4B active
|
| 21 |
+
parameters per token. It runs as a Q4_K_M GGUF through llama.cpp on a Modal L4.
|
| 22 |
+
The container is pinned to a llama.cpp build, uses an 8K context and quantized
|
| 23 |
+
KV cache. Initial visual extraction is schema-constrained without thinking.
|
| 24 |
+
The database-candidate rerank uses a bounded 256-token thinking pass, with
|
| 25 |
+
llama.cpp separating reasoning from the final JSON response.
|
| 26 |
+
The Hugging Face Space remains a small CPU application.
|
| 27 |
+
|
| 28 |
+
## Scheduling without pretending
|
| 29 |
+
|
| 30 |
+
Watering depends on species, planting context, rain, temperature, and drying
|
| 31 |
+
conditions. Waterleaf combines a care-data interval with Open-Meteo data.
|
| 32 |
+
Forecast dates are adjusted with deterministic rules and shown before export.
|
| 33 |
+
Dates beyond the reliable forecast window are visibly marked as seasonal
|
| 34 |
+
estimates. Users can edit or remove every event.
|
| 35 |
+
|
| 36 |
+
## Calendar portability
|
| 37 |
+
|
| 38 |
+
The export is one 30-day ICS file containing individual 15-minute events. Each
|
| 39 |
+
event links to a public, location-free plant profile and includes the image as
|
| 40 |
+
an ICS attachment. Calendar clients differ in how they display attachments, so
|
| 41 |
+
the profile link is the portable visual fallback.
|
| 42 |
+
|
| 43 |
+
## What to measure before submission
|
| 44 |
+
|
| 45 |
+
The final report will include at least 20 real garden examples, species top-1
|
| 46 |
+
and top-3 accuracy, genus accuracy, correction rate, and warm/cold latency.
|
| 47 |
+
It will also document manual imports into Apple Calendar, Google Calendar, and
|
| 48 |
+
Outlook, plus feedback from the gardener the project was built for.
|
docs/social-post.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Social Post Draft
|
| 2 |
+
|
| 3 |
+
I built Waterleaf for the Hugging Face Build Small Hackathon.
|
| 4 |
+
|
| 5 |
+
Take 1-3 garden photos, confirm the GBIF-backed plant match, and export a
|
| 6 |
+
weather-aware 30-day watering calendar. Each event links back to the plant
|
| 7 |
+
photo, so the reminder still makes sense weeks later.
|
| 8 |
+
|
| 9 |
+
Stack: Gradio + FastAPI on Hugging Face Spaces, Gemma 4 26B-A4B in GGUF through
|
| 10 |
+
llama.cpp on Modal, GBIF, Perenual, and Open-Meteo.
|
| 11 |
+
|
| 12 |
+
Tracks: Backyard AI, Llama Champion, Modal-powered, and Field Notes.
|
| 13 |
+
|
| 14 |
+
Space: [ADD SPACE URL]
|
| 15 |
+
Field Notes: [ADD ARTICLE URL]
|
| 16 |
+
|
docs/submission-checklist.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Submission Checklist
|
| 2 |
+
|
| 3 |
+
- [ ] Deploy Modal service and record its protected endpoint.
|
| 4 |
+
- [ ] Create Modal proxy-auth token and add credentials as Space secrets.
|
| 5 |
+
- [ ] Create and mount an HF Storage Bucket at `/data`.
|
| 6 |
+
- [ ] Add `PERENUAL_API_KEY`, `MODAL_ENDPOINT`, `MODAL_KEY`, and `MODAL_SECRET`.
|
| 7 |
+
- [ ] Deploy the Docker Space as `build-small-hackathon/waterleaf`.
|
| 8 |
+
- [ ] Run at least 20 labeled real-garden evaluation cases.
|
| 9 |
+
- [ ] Test one-, two-, and three-photo live inference.
|
| 10 |
+
- [ ] Record warm and cold latency and Q5/Q4 fallback behavior.
|
| 11 |
+
- [ ] Import ICS into Apple Calendar, Google Calendar, and Outlook.
|
| 12 |
+
- [ ] Get the known gardener's consented usability quote.
|
| 13 |
+
- [ ] Replace placeholders in Field Notes and the social post.
|
| 14 |
+
- [ ] Record and publish the 60-90 second demo.
|
| 15 |
+
- [ ] Submit before June 15, 2026.
|
| 16 |
+
|
evaluation/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Identification Evaluation
|
| 2 |
+
|
| 3 |
+
Create `evaluation/manifest.csv` with at least 20 real, consented outdoor-garden
|
| 4 |
+
examples. Use one to three image paths separated by `|` in the `images` column.
|
| 5 |
+
|
| 6 |
+
Run the live Modal evaluation:
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
MODAL_ENDPOINT=... MODAL_KEY=... MODAL_SECRET=... \
|
| 10 |
+
uv run python scripts/evaluate.py evaluation/manifest.csv
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
The report contains species top-1, species top-3, genus top-1, per-case
|
| 14 |
+
predictions, and latency. Do not publish photographs, usernames, or precise
|
| 15 |
+
locations without explicit consent.
|
| 16 |
+
|
evaluation/manifest.example.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
case_id,images,expected_scientific_name,notes
|
| 2 |
+
lavender-01,assets/sample-lavender.png,Lavandula angustifolia,Generated smoke-test image; replace with real labeled garden photographs
|
| 3 |
+
|
modal_app.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal deployment for Gemma 4 vision inference through llama.cpp."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
import time
|
| 8 |
+
import urllib.request
|
| 9 |
+
|
| 10 |
+
import modal
|
| 11 |
+
|
| 12 |
+
APP_NAME = "waterleaf-gemma4"
|
| 13 |
+
PORT = 8000
|
| 14 |
+
MODEL_REPO = "ggml-org/gemma-4-26B-A4B-it-GGUF"
|
| 15 |
+
PRIMARY_QUANT = "Q4_K_M"
|
| 16 |
+
FALLBACK_QUANT = PRIMARY_QUANT
|
| 17 |
+
LLAMA_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-cuda13-b9445"
|
| 18 |
+
PROXY_AUTH_REQUIRED = os.getenv("MODAL_PROXY_AUTH", "1") != "0"
|
| 19 |
+
|
| 20 |
+
app = modal.App(APP_NAME)
|
| 21 |
+
model_cache = modal.Volume.from_name("waterleaf-model-cache", create_if_missing=True)
|
| 22 |
+
minimum_containers = int(os.getenv("MODAL_MIN_CONTAINERS", "0"))
|
| 23 |
+
image = (
|
| 24 |
+
modal.Image.from_registry(LLAMA_IMAGE, add_python="3.12")
|
| 25 |
+
.entrypoint([])
|
| 26 |
+
.env(
|
| 27 |
+
{
|
| 28 |
+
"HF_HOME": "/models/huggingface",
|
| 29 |
+
"LLAMA_CACHE": "/models/llama.cpp",
|
| 30 |
+
"HF_XET_HIGH_PERFORMANCE": "1",
|
| 31 |
+
}
|
| 32 |
+
)
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.function(
|
| 37 |
+
image=image,
|
| 38 |
+
gpu="L4",
|
| 39 |
+
memory=32768,
|
| 40 |
+
volumes={"/models": model_cache},
|
| 41 |
+
startup_timeout=900,
|
| 42 |
+
timeout=900,
|
| 43 |
+
scaledown_window=600,
|
| 44 |
+
min_containers=minimum_containers,
|
| 45 |
+
max_containers=1,
|
| 46 |
+
)
|
| 47 |
+
@modal.concurrent(max_inputs=1)
|
| 48 |
+
@modal.web_server(
|
| 49 |
+
PORT,
|
| 50 |
+
startup_timeout=900,
|
| 51 |
+
requires_proxy_auth=PROXY_AUTH_REQUIRED,
|
| 52 |
+
)
|
| 53 |
+
def serve() -> None:
|
| 54 |
+
requested = os.getenv("WATERLEAF_MODEL_QUANT", PRIMARY_QUANT)
|
| 55 |
+
attempts = list(dict.fromkeys([requested, FALLBACK_QUANT]))
|
| 56 |
+
errors: list[str] = []
|
| 57 |
+
for quant in attempts:
|
| 58 |
+
process = subprocess.Popen(_command(quant))
|
| 59 |
+
if _wait_until_ready(process, timeout_seconds=780):
|
| 60 |
+
print(f"Waterleaf llama.cpp ready with {quant}", flush=True)
|
| 61 |
+
return
|
| 62 |
+
errors.append(f"{quant}: exit={process.poll()}")
|
| 63 |
+
if process.poll() is None:
|
| 64 |
+
process.terminate()
|
| 65 |
+
process.wait(timeout=20)
|
| 66 |
+
raise RuntimeError("llama.cpp failed to start; " + ", ".join(errors))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _command(quant: str) -> list[str]:
|
| 70 |
+
return [
|
| 71 |
+
"/app/llama-server",
|
| 72 |
+
"-hf",
|
| 73 |
+
f"{MODEL_REPO}:{quant}",
|
| 74 |
+
"--alias",
|
| 75 |
+
"waterleaf-gemma-4",
|
| 76 |
+
"--host",
|
| 77 |
+
"0.0.0.0",
|
| 78 |
+
"--port",
|
| 79 |
+
str(PORT),
|
| 80 |
+
"--ctx-size",
|
| 81 |
+
"8192",
|
| 82 |
+
"--n-gpu-layers",
|
| 83 |
+
"99",
|
| 84 |
+
"--flash-attn",
|
| 85 |
+
"on",
|
| 86 |
+
"--cache-type-k",
|
| 87 |
+
"q8_0",
|
| 88 |
+
"--cache-type-v",
|
| 89 |
+
"q8_0",
|
| 90 |
+
"--parallel",
|
| 91 |
+
"1",
|
| 92 |
+
"--jinja",
|
| 93 |
+
"--reasoning",
|
| 94 |
+
"auto",
|
| 95 |
+
"--reasoning-format",
|
| 96 |
+
"deepseek",
|
| 97 |
+
"--reasoning-budget",
|
| 98 |
+
"256",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _wait_until_ready(process: subprocess.Popen, *, timeout_seconds: int) -> bool:
|
| 103 |
+
deadline = time.monotonic() + timeout_seconds
|
| 104 |
+
while time.monotonic() < deadline:
|
| 105 |
+
if process.poll() is not None:
|
| 106 |
+
return False
|
| 107 |
+
try:
|
| 108 |
+
with urllib.request.urlopen(
|
| 109 |
+
f"http://127.0.0.1:{PORT}/health",
|
| 110 |
+
timeout=2,
|
| 111 |
+
) as response:
|
| 112 |
+
if response.status == 200:
|
| 113 |
+
return True
|
| 114 |
+
except OSError:
|
| 115 |
+
time.sleep(2)
|
| 116 |
+
return False
|
pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "waterleaf"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Plant identification and weather-aware watering calendar for the Hugging Face Build Small Hackathon."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11,<3.14"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi>=0.115,<1",
|
| 9 |
+
"gradio[oauth]>=5.49,<7",
|
| 10 |
+
"httpx>=0.28,<1",
|
| 11 |
+
"pillow>=11,<13",
|
| 12 |
+
"pydantic>=2.11,<3",
|
| 13 |
+
"python-multipart>=0.0.20,<1",
|
| 14 |
+
"uvicorn[standard]>=0.34,<1",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
[dependency-groups]
|
| 18 |
+
dev = [
|
| 19 |
+
"pytest>=8.3,<10",
|
| 20 |
+
"pytest-cov>=6,<8",
|
| 21 |
+
"ruff>=0.11,<1",
|
| 22 |
+
]
|
| 23 |
+
deploy = [
|
| 24 |
+
"modal>=1.1,<2",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
[tool.pytest.ini_options]
|
| 28 |
+
testpaths = ["tests"]
|
| 29 |
+
addopts = "-q"
|
| 30 |
+
|
| 31 |
+
[tool.ruff]
|
| 32 |
+
line-length = 100
|
| 33 |
+
target-version = "py311"
|
| 34 |
+
|
| 35 |
+
[tool.ruff.lint]
|
| 36 |
+
select = ["E", "F", "I", "B", "UP"]
|
| 37 |
+
|
| 38 |
+
[build-system]
|
| 39 |
+
requires = ["hatchling"]
|
| 40 |
+
build-backend = "hatchling.build"
|
| 41 |
+
|
| 42 |
+
[tool.hatch.build.targets.wheel]
|
| 43 |
+
packages = ["waterleaf"]
|
scripts/evaluate.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
import statistics
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from waterleaf.evaluation import score_predictions
|
| 11 |
+
from waterleaf.runtime import build_application
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> None:
|
| 15 |
+
parser = argparse.ArgumentParser(description="Evaluate Waterleaf plant identification.")
|
| 16 |
+
parser.add_argument("manifest", type=Path)
|
| 17 |
+
parser.add_argument("--out", type=Path, default=Path("evaluation/results.json"))
|
| 18 |
+
args = parser.parse_args()
|
| 19 |
+
|
| 20 |
+
application = build_application()
|
| 21 |
+
rows = []
|
| 22 |
+
latencies = []
|
| 23 |
+
with args.manifest.open(newline="") as handle:
|
| 24 |
+
for item in csv.DictReader(handle):
|
| 25 |
+
images = [Path(value) for value in item["images"].split("|") if value]
|
| 26 |
+
started = time.perf_counter()
|
| 27 |
+
result = application.identification.identify(images)
|
| 28 |
+
latency = time.perf_counter() - started
|
| 29 |
+
latencies.append(latency)
|
| 30 |
+
rows.append(
|
| 31 |
+
{
|
| 32 |
+
"case_id": item["case_id"],
|
| 33 |
+
"expected": item["expected_scientific_name"],
|
| 34 |
+
"predictions": [
|
| 35 |
+
candidate.scientific_name for candidate in result.candidates
|
| 36 |
+
],
|
| 37 |
+
"latency_seconds": round(latency, 3),
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
report = {
|
| 42 |
+
"metrics": score_predictions(rows),
|
| 43 |
+
"latency_seconds": {
|
| 44 |
+
"mean": statistics.fmean(latencies) if latencies else 0,
|
| 45 |
+
"median": statistics.median(latencies) if latencies else 0,
|
| 46 |
+
"max": max(latencies, default=0),
|
| 47 |
+
},
|
| 48 |
+
"cases": rows,
|
| 49 |
+
}
|
| 50 |
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
args.out.write_text(json.dumps(report, indent=2))
|
| 52 |
+
print(json.dumps(report["metrics"], indent=2))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
| 57 |
+
|
scripts/smoke_modal.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from waterleaf.services.llama_cpp import LlamaCppClient
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
parser = argparse.ArgumentParser(description="Run a live llama.cpp vision smoke test.")
|
| 13 |
+
parser.add_argument("images", type=Path, nargs="+")
|
| 14 |
+
args = parser.parse_args()
|
| 15 |
+
endpoint = os.environ["MODAL_ENDPOINT"]
|
| 16 |
+
client = LlamaCppClient(
|
| 17 |
+
endpoint=endpoint,
|
| 18 |
+
modal_key=os.getenv("MODAL_KEY"),
|
| 19 |
+
modal_secret=os.getenv("MODAL_SECRET"),
|
| 20 |
+
)
|
| 21 |
+
started = time.perf_counter()
|
| 22 |
+
result = client.analyze_images(args.images)
|
| 23 |
+
elapsed = time.perf_counter() - started
|
| 24 |
+
print(result.model_dump_json(indent=2))
|
| 25 |
+
print(f"latency_seconds={elapsed:.3f}")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
main()
|
| 30 |
+
|
tests/test_adapters.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from waterleaf.models import TaxonCandidate, VisualAnalysis
|
| 8 |
+
from waterleaf.services.gbif import GbifClient
|
| 9 |
+
from waterleaf.services.llama_cpp import VISUAL_SCHEMA, LlamaCppClient
|
| 10 |
+
from waterleaf.services.open_meteo import OpenMeteoClient
|
| 11 |
+
from waterleaf.services.perenual import PerenualClient
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_gbif_suggest_returns_only_plant_species():
|
| 15 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 16 |
+
assert request.url.params["q"] == "lavender"
|
| 17 |
+
assert request.url.path.endswith("/species/search")
|
| 18 |
+
return httpx.Response(
|
| 19 |
+
200,
|
| 20 |
+
json={
|
| 21 |
+
"results": [
|
| 22 |
+
{
|
| 23 |
+
"key": 999,
|
| 24 |
+
"rank": "SPECIES",
|
| 25 |
+
"kingdom": "Plantae",
|
| 26 |
+
"taxonomicStatus": "DOUBTFUL",
|
| 27 |
+
"canonicalName": "Lavandula angustifolia",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"key": 2925518,
|
| 31 |
+
"rank": "SPECIES",
|
| 32 |
+
"kingdom": "Plantae",
|
| 33 |
+
"taxonomicStatus": "ACCEPTED",
|
| 34 |
+
"scientificName": "Lavandula angustifolia Mill.",
|
| 35 |
+
"canonicalName": "Lavandula angustifolia",
|
| 36 |
+
"vernacularNames": [
|
| 37 |
+
{
|
| 38 |
+
"vernacularName": "English lavender",
|
| 39 |
+
"language": "eng",
|
| 40 |
+
}
|
| 41 |
+
],
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"key": 1,
|
| 45 |
+
"rank": "GENUS",
|
| 46 |
+
"kingdom": "Plantae",
|
| 47 |
+
"scientificName": "Lavandula",
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"key": 2,
|
| 51 |
+
"rank": "SPECIES",
|
| 52 |
+
"kingdom": "Animalia",
|
| 53 |
+
"scientificName": "Lavandula mimic",
|
| 54 |
+
},
|
| 55 |
+
]
|
| 56 |
+
},
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
client = GbifClient(http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
| 60 |
+
|
| 61 |
+
assert client.suggest("lavender")[0].model_dump() == {
|
| 62 |
+
"taxon_key": "2925518",
|
| 63 |
+
"scientific_name": "Lavandula angustifolia",
|
| 64 |
+
"common_name": "English lavender",
|
| 65 |
+
"confidence": 0.0,
|
| 66 |
+
"rationale": "",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_gbif_prioritizes_exact_common_name_and_accepted_taxa():
|
| 71 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 72 |
+
return httpx.Response(
|
| 73 |
+
200,
|
| 74 |
+
json={
|
| 75 |
+
"results": [
|
| 76 |
+
{
|
| 77 |
+
"key": 10,
|
| 78 |
+
"rank": "SPECIES",
|
| 79 |
+
"kingdom": "Plantae",
|
| 80 |
+
"taxonomicStatus": "ACCEPTED",
|
| 81 |
+
"canonicalName": "Lonchitis hirsuta",
|
| 82 |
+
"vernacularNames": [
|
| 83 |
+
{"vernacularName": "Tomato fern", "language": "eng"}
|
| 84 |
+
],
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"key": 20,
|
| 88 |
+
"rank": "SPECIES",
|
| 89 |
+
"kingdom": "Plantae",
|
| 90 |
+
"taxonomicStatus": "ACCEPTED",
|
| 91 |
+
"canonicalName": "Solanum lycopersicum",
|
| 92 |
+
"vernacularNames": [
|
| 93 |
+
{"vernacularName": "Garden tomato", "language": "eng"},
|
| 94 |
+
{"vernacularName": "Tomato", "language": "eng"},
|
| 95 |
+
],
|
| 96 |
+
},
|
| 97 |
+
]
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
client = GbifClient(http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
| 102 |
+
|
| 103 |
+
matches = client.suggest("tomato")
|
| 104 |
+
|
| 105 |
+
assert matches[0].scientific_name == "Solanum lycopersicum"
|
| 106 |
+
assert matches[0].common_name == "Tomato"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_gbif_scientific_search_prefers_accepted_record_with_common_name():
|
| 110 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 111 |
+
return httpx.Response(
|
| 112 |
+
200,
|
| 113 |
+
json={
|
| 114 |
+
"results": [
|
| 115 |
+
{
|
| 116 |
+
"key": 10,
|
| 117 |
+
"rank": "SPECIES",
|
| 118 |
+
"kingdom": "Plantae",
|
| 119 |
+
"taxonomicStatus": "DOUBTFUL",
|
| 120 |
+
"canonicalName": "Lavandula angustifolia",
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"key": 20,
|
| 124 |
+
"rank": "SPECIES",
|
| 125 |
+
"kingdom": "Plantae",
|
| 126 |
+
"taxonomicStatus": "ACCEPTED",
|
| 127 |
+
"canonicalName": "Lavandula angustifolia",
|
| 128 |
+
"vernacularNames": [
|
| 129 |
+
{
|
| 130 |
+
"vernacularName": "English lavender",
|
| 131 |
+
"language": "eng",
|
| 132 |
+
}
|
| 133 |
+
],
|
| 134 |
+
},
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
client = GbifClient(http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
| 140 |
+
|
| 141 |
+
matches = client.suggest("Lavandula angustifolia")
|
| 142 |
+
|
| 143 |
+
assert matches[0].taxon_key == "20"
|
| 144 |
+
assert matches[0].common_name == "English lavender"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_perenual_fetches_details_once_then_uses_cache(tmp_path):
|
| 148 |
+
calls = []
|
| 149 |
+
|
| 150 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 151 |
+
calls.append(str(request.url))
|
| 152 |
+
if "species-list" in request.url.path:
|
| 153 |
+
return httpx.Response(200, json={"data": [{"id": 77}]})
|
| 154 |
+
return httpx.Response(
|
| 155 |
+
200,
|
| 156 |
+
json={
|
| 157 |
+
"id": 77,
|
| 158 |
+
"common_name": "English lavender",
|
| 159 |
+
"scientific_name": ["Lavandula angustifolia"],
|
| 160 |
+
"watering": "Minimum",
|
| 161 |
+
"watering_general_benchmark": {"value": "7-10", "unit": "days"},
|
| 162 |
+
"sunlight": ["full sun"],
|
| 163 |
+
},
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
client = PerenualClient(
|
| 167 |
+
api_key="test-key",
|
| 168 |
+
cache_path=tmp_path / "care-cache.json",
|
| 169 |
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
first = client.get_care("Lavandula angustifolia")
|
| 173 |
+
second = client.get_care("Lavandula angustifolia")
|
| 174 |
+
|
| 175 |
+
assert first == second
|
| 176 |
+
assert first.min_days == 7
|
| 177 |
+
assert first.max_days == 10
|
| 178 |
+
assert first.sunlight == ["full sun"]
|
| 179 |
+
assert len(calls) == 2
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_open_meteo_geocodes_and_parses_forecast():
|
| 183 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 184 |
+
if request.url.host == "geocoding-api.open-meteo.com":
|
| 185 |
+
return httpx.Response(
|
| 186 |
+
200,
|
| 187 |
+
json={
|
| 188 |
+
"results": [
|
| 189 |
+
{
|
| 190 |
+
"name": "Stockholm",
|
| 191 |
+
"country": "Sweden",
|
| 192 |
+
"latitude": 59.33,
|
| 193 |
+
"longitude": 18.07,
|
| 194 |
+
"timezone": "Europe/Stockholm",
|
| 195 |
+
}
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
)
|
| 199 |
+
return httpx.Response(
|
| 200 |
+
200,
|
| 201 |
+
json={
|
| 202 |
+
"timezone": "Europe/Stockholm",
|
| 203 |
+
"daily": {
|
| 204 |
+
"time": ["2026-06-08", "2026-06-09"],
|
| 205 |
+
"precipitation_sum": [0.0, 7.2],
|
| 206 |
+
"temperature_2m_max": [22.0, 19.0],
|
| 207 |
+
"et0_fao_evapotranspiration": [3.1, 1.4],
|
| 208 |
+
},
|
| 209 |
+
},
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
client = OpenMeteoClient(
|
| 213 |
+
http_client=httpx.Client(transport=httpx.MockTransport(handler))
|
| 214 |
+
)
|
| 215 |
+
location = client.geocode("Stockholm")
|
| 216 |
+
forecast = client.forecast(location.latitude, location.longitude)
|
| 217 |
+
|
| 218 |
+
assert location.display_name == "Stockholm, Sweden"
|
| 219 |
+
assert location.timezone == "Europe/Stockholm"
|
| 220 |
+
assert forecast[1].precipitation_mm == 7.2
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_llama_cpp_sends_all_images_and_constrained_schema(tmp_path):
|
| 224 |
+
image_paths: list[Path] = []
|
| 225 |
+
for index in range(2):
|
| 226 |
+
path = tmp_path / f"plant-{index}.jpg"
|
| 227 |
+
Image.new("RGB", (20, 20), color=(30 + index, 120, 40)).save(path)
|
| 228 |
+
image_paths.append(path)
|
| 229 |
+
|
| 230 |
+
captured = {}
|
| 231 |
+
|
| 232 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 233 |
+
captured.update(json.loads(request.content))
|
| 234 |
+
return httpx.Response(
|
| 235 |
+
200,
|
| 236 |
+
json={
|
| 237 |
+
"choices": [
|
| 238 |
+
{
|
| 239 |
+
"message": {
|
| 240 |
+
"content": json.dumps(
|
| 241 |
+
{
|
| 242 |
+
"traits": ["purple flower spikes"],
|
| 243 |
+
"proposed_names": ["Lavandula angustifolia"],
|
| 244 |
+
"is_container": True,
|
| 245 |
+
"size_label": "medium",
|
| 246 |
+
}
|
| 247 |
+
)
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
]
|
| 251 |
+
},
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
client = LlamaCppClient(
|
| 255 |
+
endpoint="https://modal.example",
|
| 256 |
+
modal_key="key",
|
| 257 |
+
modal_secret="secret",
|
| 258 |
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
| 259 |
+
)
|
| 260 |
+
result = client.analyze_images(image_paths)
|
| 261 |
+
|
| 262 |
+
content = captured["messages"][1]["content"]
|
| 263 |
+
assert len([part for part in content if part["type"] == "image_url"]) == 2
|
| 264 |
+
assert captured["response_format"]["type"] == "json_schema"
|
| 265 |
+
assert captured["response_format"]["json_schema"] == {
|
| 266 |
+
"name": "visual_analysis",
|
| 267 |
+
"schema": VISUAL_SCHEMA,
|
| 268 |
+
"strict": True,
|
| 269 |
+
}
|
| 270 |
+
assert captured["chat_template_kwargs"] == {"enable_thinking": False}
|
| 271 |
+
assert result.proposed_names == ["Lavandula angustifolia"]
|
| 272 |
+
assert result.is_container is True
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def test_llama_cpp_enables_thinking_for_candidate_reranking():
|
| 276 |
+
captured = {}
|
| 277 |
+
|
| 278 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 279 |
+
captured.update(json.loads(request.content))
|
| 280 |
+
return httpx.Response(
|
| 281 |
+
200,
|
| 282 |
+
json={
|
| 283 |
+
"choices": [
|
| 284 |
+
{
|
| 285 |
+
"message": {
|
| 286 |
+
"content": json.dumps(
|
| 287 |
+
{
|
| 288 |
+
"ranking": [
|
| 289 |
+
{
|
| 290 |
+
"taxon_key": "2925518",
|
| 291 |
+
"confidence": 0.92,
|
| 292 |
+
"rationale": "Flower and leaf morphology match.",
|
| 293 |
+
}
|
| 294 |
+
]
|
| 295 |
+
}
|
| 296 |
+
)
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
client = LlamaCppClient(
|
| 304 |
+
endpoint="https://modal.example",
|
| 305 |
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
| 306 |
+
)
|
| 307 |
+
ranking = client.rerank(
|
| 308 |
+
[],
|
| 309 |
+
VisualAnalysis(
|
| 310 |
+
traits=["purple flower spikes"],
|
| 311 |
+
proposed_names=["Lavandula angustifolia"],
|
| 312 |
+
is_container=True,
|
| 313 |
+
size_label="medium",
|
| 314 |
+
),
|
| 315 |
+
[
|
| 316 |
+
TaxonCandidate(
|
| 317 |
+
taxon_key="2925518",
|
| 318 |
+
scientific_name="Lavandula angustifolia",
|
| 319 |
+
common_name="English lavender",
|
| 320 |
+
)
|
| 321 |
+
],
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
assert captured["chat_template_kwargs"] == {"enable_thinking": True}
|
| 325 |
+
assert ranking[0]["taxon_key"] == "2925518"
|
tests/test_application.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, time
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
from waterleaf.application import WaterleafApplication
|
| 7 |
+
from waterleaf.models import CareProfile, LocationMatch, TaxonCandidate, WeatherDay
|
| 8 |
+
from waterleaf.storage import GardenStore
|
| 9 |
+
from waterleaf.web import create_web_app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class FakeCare:
|
| 13 |
+
def get_care(self, scientific_name):
|
| 14 |
+
return CareProfile(
|
| 15 |
+
scientific_name=scientific_name,
|
| 16 |
+
common_name="English lavender",
|
| 17 |
+
min_days=7,
|
| 18 |
+
max_days=9,
|
| 19 |
+
watering_label="Minimum",
|
| 20 |
+
sunlight=["full sun"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FakeWeather:
|
| 25 |
+
def geocode(self, query):
|
| 26 |
+
assert query == "Stockholm"
|
| 27 |
+
return LocationMatch(
|
| 28 |
+
display_name="Stockholm, Sweden",
|
| 29 |
+
latitude=59.33,
|
| 30 |
+
longitude=18.07,
|
| 31 |
+
timezone="Europe/Stockholm",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def forecast(self, latitude, longitude):
|
| 35 |
+
return [
|
| 36 |
+
WeatherDay(
|
| 37 |
+
date=date(2026, 6, 16),
|
| 38 |
+
precipitation_mm=7.0,
|
| 39 |
+
max_temperature_c=20.0,
|
| 40 |
+
et0_mm=2.0,
|
| 41 |
+
)
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_application_saves_plan_and_exports_whole_garden(tmp_path):
|
| 46 |
+
source = tmp_path / "lavender.png"
|
| 47 |
+
Image.new("RGB", (300, 500), color=(90, 120, 60)).save(source)
|
| 48 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 49 |
+
application = WaterleafApplication(
|
| 50 |
+
store=store,
|
| 51 |
+
media_directory=tmp_path / "media",
|
| 52 |
+
export_directory=tmp_path / "exports",
|
| 53 |
+
public_base_url="https://waterleaf.example",
|
| 54 |
+
care=FakeCare(),
|
| 55 |
+
weather=FakeWeather(),
|
| 56 |
+
)
|
| 57 |
+
candidate = TaxonCandidate(
|
| 58 |
+
taxon_key="2925518",
|
| 59 |
+
scientific_name="Lavandula angustifolia",
|
| 60 |
+
common_name="English lavender",
|
| 61 |
+
confidence=0.91,
|
| 62 |
+
rationale="Purple spikes and narrow leaves",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
plan = application.preview_schedule(
|
| 66 |
+
candidate=candidate,
|
| 67 |
+
location_query="Stockholm",
|
| 68 |
+
is_container=True,
|
| 69 |
+
size_label="medium",
|
| 70 |
+
start=date(2026, 6, 8),
|
| 71 |
+
)
|
| 72 |
+
saved = application.save_plant(
|
| 73 |
+
owner="alice",
|
| 74 |
+
nickname="Patio lavender",
|
| 75 |
+
candidate=candidate,
|
| 76 |
+
source_image=source,
|
| 77 |
+
preferred_time=time(7, 30),
|
| 78 |
+
plan=plan,
|
| 79 |
+
)
|
| 80 |
+
exported = application.export_garden("alice", generated_at="20260608T100000Z")
|
| 81 |
+
|
| 82 |
+
assert saved.image_id
|
| 83 |
+
assert store.get_schedule("alice", saved.id)
|
| 84 |
+
content = exported.read_text()
|
| 85 |
+
unfolded = content.replace("\n ", "")
|
| 86 |
+
assert "SUMMARY:Water Patio lavender" in content
|
| 87 |
+
assert f"https://waterleaf.example/plants/{saved.public_slug}" in content
|
| 88 |
+
assert f"https://waterleaf.example/media/{saved.image_id}.jpg" in unfolded
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_public_profile_and_media_routes_exclude_private_location(tmp_path):
|
| 92 |
+
source = tmp_path / "lavender.png"
|
| 93 |
+
Image.new("RGB", (100, 100), color=(90, 120, 60)).save(source)
|
| 94 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 95 |
+
application = WaterleafApplication(
|
| 96 |
+
store=store,
|
| 97 |
+
media_directory=tmp_path / "media",
|
| 98 |
+
export_directory=tmp_path / "exports",
|
| 99 |
+
public_base_url="https://waterleaf.example",
|
| 100 |
+
care=FakeCare(),
|
| 101 |
+
weather=FakeWeather(),
|
| 102 |
+
)
|
| 103 |
+
candidate = TaxonCandidate(
|
| 104 |
+
taxon_key="2925518",
|
| 105 |
+
scientific_name="Lavandula angustifolia",
|
| 106 |
+
common_name="English lavender",
|
| 107 |
+
)
|
| 108 |
+
plan = application.preview_schedule(
|
| 109 |
+
candidate=candidate,
|
| 110 |
+
location_query="Stockholm",
|
| 111 |
+
is_container=True,
|
| 112 |
+
size_label="medium",
|
| 113 |
+
start=date(2026, 6, 8),
|
| 114 |
+
)
|
| 115 |
+
saved = application.save_plant(
|
| 116 |
+
owner="alice",
|
| 117 |
+
nickname="Patio lavender",
|
| 118 |
+
candidate=candidate,
|
| 119 |
+
source_image=source,
|
| 120 |
+
preferred_time=time(7, 30),
|
| 121 |
+
plan=plan,
|
| 122 |
+
)
|
| 123 |
+
client = TestClient(create_web_app(application, mount_ui=False))
|
| 124 |
+
|
| 125 |
+
health = client.get("/health")
|
| 126 |
+
profile = client.get(f"/plants/{saved.public_slug}")
|
| 127 |
+
media = client.get(f"/media/{saved.image_id}.jpg")
|
| 128 |
+
|
| 129 |
+
assert health.json() == {"status": "ok"}
|
| 130 |
+
assert profile.status_code == 200
|
| 131 |
+
assert "Patio lavender" in profile.text
|
| 132 |
+
assert "Lavandula angustifolia" in profile.text
|
| 133 |
+
assert "7-9 days" in profile.text
|
| 134 |
+
assert "2026-06-" in profile.text
|
| 135 |
+
assert "Stockholm" not in profile.text
|
| 136 |
+
assert "alice" not in profile.text
|
| 137 |
+
assert media.status_code == 200
|
| 138 |
+
assert media.headers["content-type"] == "image/jpeg"
|
| 139 |
+
assert client.get("/media/../../private.jpg").status_code == 404
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_application_delete_removes_unreferenced_media(tmp_path):
|
| 143 |
+
source = tmp_path / "lavender.png"
|
| 144 |
+
Image.new("RGB", (100, 100), color=(90, 120, 60)).save(source)
|
| 145 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 146 |
+
application = WaterleafApplication(
|
| 147 |
+
store=store,
|
| 148 |
+
media_directory=tmp_path / "media",
|
| 149 |
+
export_directory=tmp_path / "exports",
|
| 150 |
+
public_base_url="https://waterleaf.example",
|
| 151 |
+
care=FakeCare(),
|
| 152 |
+
weather=FakeWeather(),
|
| 153 |
+
)
|
| 154 |
+
candidate = TaxonCandidate(
|
| 155 |
+
taxon_key="2925518",
|
| 156 |
+
scientific_name="Lavandula angustifolia",
|
| 157 |
+
common_name="English lavender",
|
| 158 |
+
)
|
| 159 |
+
plan = application.preview_schedule(
|
| 160 |
+
candidate=candidate,
|
| 161 |
+
location_query="Stockholm",
|
| 162 |
+
is_container=True,
|
| 163 |
+
size_label="medium",
|
| 164 |
+
start=date(2026, 6, 8),
|
| 165 |
+
)
|
| 166 |
+
first = application.save_plant(
|
| 167 |
+
owner="alice",
|
| 168 |
+
nickname="Patio lavender",
|
| 169 |
+
candidate=candidate,
|
| 170 |
+
source_image=source,
|
| 171 |
+
preferred_time=time(7, 30),
|
| 172 |
+
plan=plan,
|
| 173 |
+
)
|
| 174 |
+
second = application.save_plant(
|
| 175 |
+
owner="alice",
|
| 176 |
+
nickname="Second lavender",
|
| 177 |
+
candidate=candidate,
|
| 178 |
+
source_image=source,
|
| 179 |
+
preferred_time=time(7, 30),
|
| 180 |
+
plan=plan,
|
| 181 |
+
)
|
| 182 |
+
media_path = application.media_directory / f"{first.image_id}.jpg"
|
| 183 |
+
|
| 184 |
+
assert first.image_id == second.image_id
|
| 185 |
+
assert media_path.exists()
|
| 186 |
+
assert application.delete_plant("alice", first.id) is True
|
| 187 |
+
assert media_path.exists()
|
| 188 |
+
assert application.delete_plant("alice", second.id) is True
|
| 189 |
+
assert not media_path.exists()
|
tests/test_calendar.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, time
|
| 2 |
+
|
| 3 |
+
from waterleaf.calendar import CalendarPlant, build_garden_ics
|
| 4 |
+
from waterleaf.models import WateringEvent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_calendar_contains_timed_events_alarm_profile_and_attachment():
|
| 8 |
+
plant = CalendarPlant(
|
| 9 |
+
id="plant-1",
|
| 10 |
+
nickname="Front rose",
|
| 11 |
+
common_name="Dog rose",
|
| 12 |
+
scientific_name="Rosa canina",
|
| 13 |
+
timezone="Europe/Stockholm",
|
| 14 |
+
preferred_time=time(7, 30),
|
| 15 |
+
profile_url="https://waterleaf.example/plants/rose-abc",
|
| 16 |
+
image_url="https://waterleaf.example/media/image-1",
|
| 17 |
+
events=[
|
| 18 |
+
WateringEvent(
|
| 19 |
+
date=date(2026, 6, 12),
|
| 20 |
+
reason="Deferred after forecast rain",
|
| 21 |
+
confidence="forecast",
|
| 22 |
+
)
|
| 23 |
+
],
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
content = build_garden_ics([plant], generated_at="20260608T100000Z")
|
| 27 |
+
|
| 28 |
+
assert "X-WR-CALNAME:Waterleaf" in content
|
| 29 |
+
assert "SUMMARY:Water Front rose" in content
|
| 30 |
+
assert "DTSTART;TZID=Europe/Stockholm:20260612T073000" in content
|
| 31 |
+
assert "DTEND;TZID=Europe/Stockholm:20260612T074500" in content
|
| 32 |
+
assert "TRIGGER:-PT30M" in content
|
| 33 |
+
assert "URL:https://waterleaf.example/plants/rose-abc" in content
|
| 34 |
+
assert "ATTACH;FMTTYPE=image/jpeg:https://waterleaf.example/media/image-1" in content
|
| 35 |
+
assert "Dog rose (Rosa canina)" in content
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_calendar_uid_is_stable_for_same_plant_and_date():
|
| 39 |
+
plant = CalendarPlant(
|
| 40 |
+
id="plant-1",
|
| 41 |
+
nickname="Mint",
|
| 42 |
+
common_name="Spearmint",
|
| 43 |
+
scientific_name="Mentha spicata",
|
| 44 |
+
timezone="UTC",
|
| 45 |
+
preferred_time=time(8, 0),
|
| 46 |
+
profile_url="https://example.com/plants/mint",
|
| 47 |
+
image_url="https://example.com/media/mint",
|
| 48 |
+
events=[
|
| 49 |
+
WateringEvent(
|
| 50 |
+
date=date(2026, 6, 12),
|
| 51 |
+
reason="Species care baseline",
|
| 52 |
+
confidence="baseline",
|
| 53 |
+
)
|
| 54 |
+
],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
first = build_garden_ics([plant], generated_at="20260608T100000Z")
|
| 58 |
+
second = build_garden_ics([plant], generated_at="20260609T100000Z")
|
| 59 |
+
|
| 60 |
+
first_uid = next(line for line in first.splitlines() if line.startswith("UID:"))
|
| 61 |
+
second_uid = next(line for line in second.splitlines() if line.startswith("UID:"))
|
| 62 |
+
assert first_uid == second_uid
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_calendar_escapes_text_and_folds_long_lines():
|
| 66 |
+
plant = CalendarPlant(
|
| 67 |
+
id="plant-2",
|
| 68 |
+
nickname="Herbs, north bed",
|
| 69 |
+
common_name="A very long common plant name used to force an RFC line fold",
|
| 70 |
+
scientific_name="Mentha longifolia",
|
| 71 |
+
timezone="UTC",
|
| 72 |
+
preferred_time=time(9, 0),
|
| 73 |
+
profile_url="https://example.com/plants/" + ("very-long-segment-" * 8),
|
| 74 |
+
image_url="https://example.com/media/image-2",
|
| 75 |
+
events=[
|
| 76 |
+
WateringEvent(
|
| 77 |
+
date=date(2026, 6, 14),
|
| 78 |
+
reason="Line one\nLine two; check",
|
| 79 |
+
confidence="seasonal",
|
| 80 |
+
)
|
| 81 |
+
],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
content = build_garden_ics([plant], generated_at="20260608T100000Z")
|
| 85 |
+
|
| 86 |
+
assert "SUMMARY:Water Herbs\\, north bed" in content
|
| 87 |
+
assert "Line one\\nLine two\\; check" in content.replace("\r\n ", "")
|
| 88 |
+
for line in content.split("\r\n"):
|
| 89 |
+
assert len(line.encode("utf-8")) <= 75
|
| 90 |
+
|
tests/test_evaluation.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from waterleaf.evaluation import score_predictions
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_evaluation_reports_species_genus_and_top_three_accuracy():
|
| 5 |
+
rows = [
|
| 6 |
+
{
|
| 7 |
+
"expected": "Lavandula angustifolia",
|
| 8 |
+
"predictions": [
|
| 9 |
+
"Lavandula angustifolia",
|
| 10 |
+
"Salvia officinalis",
|
| 11 |
+
"Salvia rosmarinus",
|
| 12 |
+
],
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"expected": "Salvia officinalis",
|
| 16 |
+
"predictions": [
|
| 17 |
+
"Salvia rosmarinus",
|
| 18 |
+
"Salvia officinalis",
|
| 19 |
+
],
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"expected": "Rosa canina",
|
| 23 |
+
"predictions": ["Rosa rubiginosa"],
|
| 24 |
+
},
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
metrics = score_predictions(rows)
|
| 28 |
+
|
| 29 |
+
assert metrics["count"] == 3
|
| 30 |
+
assert metrics["species_top_1"] == 1 / 3
|
| 31 |
+
assert metrics["species_top_3"] == 2 / 3
|
| 32 |
+
assert metrics["genus_top_1"] == 1.0
|
| 33 |
+
|
tests/test_identification.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from waterleaf.models import TaxonCandidate, VisualAnalysis
|
| 4 |
+
from waterleaf.services.identification import IdentificationService
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class FakeVision:
|
| 8 |
+
def analyze_images(self, image_paths):
|
| 9 |
+
assert image_paths == [Path("plant.jpg")]
|
| 10 |
+
return VisualAnalysis(
|
| 11 |
+
traits=["purple flower spikes", "narrow gray-green leaves"],
|
| 12 |
+
proposed_names=["Lavandula angustifolia", "Salvia officinalis"],
|
| 13 |
+
is_container=True,
|
| 14 |
+
size_label="medium",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
def rerank(self, image_paths, visual, candidates):
|
| 18 |
+
assert all(candidate.taxon_key for candidate in candidates)
|
| 19 |
+
return [
|
| 20 |
+
{"taxon_key": "lavender", "confidence": 0.91, "rationale": "Flower and leaf match"},
|
| 21 |
+
{"taxon_key": "sage", "confidence": 0.22, "rationale": "Leaf color only"},
|
| 22 |
+
{"taxon_key": "invented", "confidence": 0.99, "rationale": "Must be ignored"},
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class FakeTaxonomy:
|
| 27 |
+
def suggest(self, query, limit=5):
|
| 28 |
+
if query == "Lavandula angustifolia":
|
| 29 |
+
return [
|
| 30 |
+
TaxonCandidate(
|
| 31 |
+
taxon_key="lavender",
|
| 32 |
+
scientific_name="Lavandula angustifolia",
|
| 33 |
+
common_name="English lavender",
|
| 34 |
+
)
|
| 35 |
+
]
|
| 36 |
+
if query == "Salvia officinalis":
|
| 37 |
+
return [
|
| 38 |
+
TaxonCandidate(
|
| 39 |
+
taxon_key="sage",
|
| 40 |
+
scientific_name="Salvia officinalis",
|
| 41 |
+
common_name="Common sage",
|
| 42 |
+
)
|
| 43 |
+
]
|
| 44 |
+
return []
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_identification_only_returns_grounded_reranked_candidates():
|
| 48 |
+
service = IdentificationService(vision=FakeVision(), taxonomy=FakeTaxonomy())
|
| 49 |
+
|
| 50 |
+
result = service.identify([Path("plant.jpg")])
|
| 51 |
+
|
| 52 |
+
assert [candidate.taxon_key for candidate in result.candidates] == [
|
| 53 |
+
"lavender",
|
| 54 |
+
"sage",
|
| 55 |
+
]
|
| 56 |
+
assert result.candidates[0].confidence == 0.91
|
| 57 |
+
assert result.visual.is_container is True
|
| 58 |
+
|
tests/test_images.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
|
| 3 |
+
from waterleaf.images import normalize_plant_image
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_normalize_image_resizes_strips_exif_and_writes_jpeg(tmp_path):
|
| 7 |
+
source = tmp_path / "source.jpg"
|
| 8 |
+
image = Image.new("RGB", (2400, 1200), color=(70, 130, 70))
|
| 9 |
+
exif = Image.Exif()
|
| 10 |
+
exif[0x010E] = "private garden note"
|
| 11 |
+
image.save(source, exif=exif)
|
| 12 |
+
|
| 13 |
+
stored = normalize_plant_image(source, tmp_path / "media")
|
| 14 |
+
|
| 15 |
+
assert stored.path.suffix == ".jpg"
|
| 16 |
+
assert stored.width == 1600
|
| 17 |
+
assert stored.height == 800
|
| 18 |
+
with Image.open(stored.path) as normalized:
|
| 19 |
+
assert normalized.getexif() == {}
|
| 20 |
+
assert normalized.mode == "RGB"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_normalize_image_generates_stable_content_id(tmp_path):
|
| 24 |
+
source = tmp_path / "source.png"
|
| 25 |
+
Image.new("RGB", (120, 80), color=(120, 80, 40)).save(source)
|
| 26 |
+
|
| 27 |
+
first = normalize_plant_image(source, tmp_path / "media")
|
| 28 |
+
second = normalize_plant_image(source, tmp_path / "media")
|
| 29 |
+
|
| 30 |
+
assert first.id == second.id
|
| 31 |
+
assert first.path == second.path
|
| 32 |
+
|
tests/test_rate_limit.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from waterleaf.rate_limit import SlidingWindowRateLimiter
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_rate_limiter_blocks_after_limit_inside_window():
|
| 5 |
+
limiter = SlidingWindowRateLimiter(limit=2, window_seconds=60)
|
| 6 |
+
|
| 7 |
+
assert limiter.allow("203.0.113.10", now=100.0) is True
|
| 8 |
+
assert limiter.allow("203.0.113.10", now=110.0) is True
|
| 9 |
+
assert limiter.allow("203.0.113.10", now=120.0) is False
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_rate_limiter_expires_old_attempts():
|
| 13 |
+
limiter = SlidingWindowRateLimiter(limit=1, window_seconds=60)
|
| 14 |
+
|
| 15 |
+
assert limiter.allow("203.0.113.10", now=100.0) is True
|
| 16 |
+
assert limiter.allow("203.0.113.10", now=161.0) is True
|
| 17 |
+
|
tests/test_runtime.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from waterleaf.runtime import build_application
|
| 2 |
+
from waterleaf.services.demo import DemoTaxonomy
|
| 3 |
+
from waterleaf.settings import Settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_settings_derive_public_url_from_space_host(monkeypatch, tmp_path):
|
| 7 |
+
monkeypatch.setenv("SPACE_HOST", "hkaraoguz-waterleaf.hf.space")
|
| 8 |
+
monkeypatch.setenv("WATERLEAF_DATA_DIR", str(tmp_path))
|
| 9 |
+
|
| 10 |
+
settings = Settings.from_env()
|
| 11 |
+
|
| 12 |
+
assert settings.public_base_url == "https://hkaraoguz-waterleaf.hf.space"
|
| 13 |
+
assert settings.database_path == tmp_path / "waterleaf.sqlite3"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_runtime_uses_demo_identification_without_modal_endpoint(tmp_path):
|
| 17 |
+
settings = Settings(
|
| 18 |
+
data_directory=tmp_path,
|
| 19 |
+
public_base_url="http://localhost:7860",
|
| 20 |
+
perenual_api_key=None,
|
| 21 |
+
modal_endpoint=None,
|
| 22 |
+
modal_key=None,
|
| 23 |
+
modal_secret=None,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
application = build_application(settings)
|
| 27 |
+
|
| 28 |
+
assert application.identification is not None
|
| 29 |
+
assert isinstance(application.taxonomy, DemoTaxonomy)
|
tests/test_scheduling.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
|
| 3 |
+
from waterleaf.models import CareBenchmark, PlantContext, WeatherDay
|
| 4 |
+
from waterleaf.scheduling import build_watering_schedule
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_container_plant_uses_shorter_interval():
|
| 8 |
+
schedule = build_watering_schedule(
|
| 9 |
+
start=date(2026, 6, 8),
|
| 10 |
+
days=30,
|
| 11 |
+
care=CareBenchmark(min_days=6, max_days=8),
|
| 12 |
+
context=PlantContext(is_container=True, size_label="medium"),
|
| 13 |
+
weather=[],
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
assert [item.date for item in schedule[:3]] == [
|
| 17 |
+
date(2026, 6, 13),
|
| 18 |
+
date(2026, 6, 18),
|
| 19 |
+
date(2026, 6, 23),
|
| 20 |
+
]
|
| 21 |
+
assert all(item.confidence == "baseline" for item in schedule[:3])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_meaningful_rain_defers_near_term_watering():
|
| 25 |
+
schedule = build_watering_schedule(
|
| 26 |
+
start=date(2026, 6, 8),
|
| 27 |
+
days=16,
|
| 28 |
+
care=CareBenchmark(min_days=4, max_days=4),
|
| 29 |
+
context=PlantContext(is_container=False, size_label="large"),
|
| 30 |
+
weather=[
|
| 31 |
+
WeatherDay(
|
| 32 |
+
date=date(2026, 6, 12),
|
| 33 |
+
precipitation_mm=8.0,
|
| 34 |
+
max_temperature_c=19.0,
|
| 35 |
+
et0_mm=2.0,
|
| 36 |
+
)
|
| 37 |
+
],
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
assert schedule[0].date == date(2026, 6, 14)
|
| 41 |
+
assert schedule[0].reason == "Deferred after forecast rain"
|
| 42 |
+
assert schedule[0].confidence == "forecast"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_high_heat_advances_near_term_watering_by_one_day():
|
| 46 |
+
schedule = build_watering_schedule(
|
| 47 |
+
start=date(2026, 6, 8),
|
| 48 |
+
days=16,
|
| 49 |
+
care=CareBenchmark(min_days=5, max_days=5),
|
| 50 |
+
context=PlantContext(is_container=False, size_label="small"),
|
| 51 |
+
weather=[
|
| 52 |
+
WeatherDay(
|
| 53 |
+
date=date(2026, 6, 13),
|
| 54 |
+
precipitation_mm=0.0,
|
| 55 |
+
max_temperature_c=32.0,
|
| 56 |
+
et0_mm=5.8,
|
| 57 |
+
)
|
| 58 |
+
],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
assert schedule[0].date == date(2026, 6, 12)
|
| 62 |
+
assert schedule[0].reason == "Advanced for hot, drying weather"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_dates_after_forecast_window_are_marked_seasonal():
|
| 66 |
+
schedule = build_watering_schedule(
|
| 67 |
+
start=date(2026, 6, 8),
|
| 68 |
+
days=30,
|
| 69 |
+
care=CareBenchmark(min_days=7, max_days=7),
|
| 70 |
+
context=PlantContext(is_container=False, size_label="medium"),
|
| 71 |
+
weather=[],
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
assert schedule[2].date == date(2026, 6, 29)
|
| 75 |
+
assert schedule[2].confidence == "seasonal"
|
| 76 |
+
assert schedule[2].reason == "Seasonal care baseline"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_missing_care_interval_requires_user_input():
|
| 80 |
+
try:
|
| 81 |
+
build_watering_schedule(
|
| 82 |
+
start=date(2026, 6, 8),
|
| 83 |
+
days=30,
|
| 84 |
+
care=CareBenchmark(min_days=None, max_days=None),
|
| 85 |
+
context=PlantContext(is_container=False, size_label="medium"),
|
| 86 |
+
weather=[],
|
| 87 |
+
)
|
| 88 |
+
except ValueError as exc:
|
| 89 |
+
assert str(exc) == "A watering interval is required"
|
| 90 |
+
else:
|
| 91 |
+
raise AssertionError("Expected a missing interval to be rejected")
|
tests/test_storage.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import time
|
| 2 |
+
|
| 3 |
+
from waterleaf.models import PlantDraft
|
| 4 |
+
from waterleaf.storage import GardenStore
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _draft() -> PlantDraft:
|
| 8 |
+
return PlantDraft(
|
| 9 |
+
nickname="Patio lavender",
|
| 10 |
+
common_name="English lavender",
|
| 11 |
+
scientific_name="Lavandula angustifolia",
|
| 12 |
+
taxon_key="2925518",
|
| 13 |
+
location_name="Stockholm, Sweden",
|
| 14 |
+
latitude=59.32932,
|
| 15 |
+
longitude=18.06858,
|
| 16 |
+
timezone="Europe/Stockholm",
|
| 17 |
+
preferred_time=time(7, 30),
|
| 18 |
+
is_container=True,
|
| 19 |
+
size_label="medium",
|
| 20 |
+
image_id="image-1",
|
| 21 |
+
care_min_days=7,
|
| 22 |
+
care_max_days=10,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_store_isolates_plants_by_owner(tmp_path):
|
| 27 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 28 |
+
plant = store.save_plant("alice", _draft())
|
| 29 |
+
|
| 30 |
+
assert store.list_plants("alice") == [plant]
|
| 31 |
+
assert store.list_plants("bob") == []
|
| 32 |
+
assert store.get_plant("bob", plant.id) is None
|
| 33 |
+
assert store.get_plant("alice", plant.id) == plant
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_store_rounds_private_coordinates_and_exposes_safe_public_record(tmp_path):
|
| 37 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 38 |
+
plant = store.save_plant("alice", _draft())
|
| 39 |
+
|
| 40 |
+
assert plant.latitude == 59.33
|
| 41 |
+
assert plant.longitude == 18.07
|
| 42 |
+
public = store.get_public_plant(plant.public_slug)
|
| 43 |
+
|
| 44 |
+
assert public is not None
|
| 45 |
+
assert public.nickname == "Patio lavender"
|
| 46 |
+
assert public.scientific_name == "Lavandula angustifolia"
|
| 47 |
+
assert not hasattr(public, "owner")
|
| 48 |
+
assert not hasattr(public, "latitude")
|
| 49 |
+
assert len(plant.public_slug) >= 20
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_only_owner_can_delete_plant(tmp_path):
|
| 53 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 54 |
+
plant = store.save_plant("alice", _draft())
|
| 55 |
+
|
| 56 |
+
assert store.delete_plant("bob", plant.id) is False
|
| 57 |
+
assert store.get_plant("alice", plant.id) is not None
|
| 58 |
+
assert store.delete_plant("alice", plant.id) is True
|
| 59 |
+
assert store.get_public_plant(plant.public_slug) is None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_store_replaces_schedule_atomically(tmp_path):
|
| 63 |
+
store = GardenStore(tmp_path / "waterleaf.sqlite3")
|
| 64 |
+
plant = store.save_plant("alice", _draft())
|
| 65 |
+
|
| 66 |
+
store.replace_schedule(
|
| 67 |
+
"alice",
|
| 68 |
+
plant.id,
|
| 69 |
+
[
|
| 70 |
+
{"date": "2026-06-12", "reason": "Forecast", "confidence": "forecast"},
|
| 71 |
+
{"date": "2026-06-18", "reason": "Baseline", "confidence": "baseline"},
|
| 72 |
+
],
|
| 73 |
+
)
|
| 74 |
+
store.replace_schedule(
|
| 75 |
+
"alice",
|
| 76 |
+
plant.id,
|
| 77 |
+
[{"date": "2026-06-14", "reason": "Edited", "confidence": "manual"}],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
assert store.get_schedule("alice", plant.id) == [
|
| 81 |
+
{"date": "2026-06-14", "reason": "Edited", "confidence": "manual"}
|
| 82 |
+
]
|
| 83 |
+
|
tests/test_ui.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from waterleaf.application import WaterleafApplication
|
| 6 |
+
from waterleaf.models import TaxonCandidate
|
| 7 |
+
from waterleaf.storage import GardenStore
|
| 8 |
+
from waterleaf.ui import _candidate_label, _parse_optional_interval, build_ui
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class NoopCare:
|
| 12 |
+
def get_care(self, scientific_name):
|
| 13 |
+
raise AssertionError("Not called while building UI")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class NoopWeather:
|
| 17 |
+
def geocode(self, query):
|
| 18 |
+
raise AssertionError("Not called while building UI")
|
| 19 |
+
|
| 20 |
+
def forecast(self, latitude, longitude):
|
| 21 |
+
raise AssertionError("Not called while building UI")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class NoopIdentification:
|
| 25 |
+
def identify(self, image_paths):
|
| 26 |
+
raise AssertionError("Not called while building UI")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class NoopTaxonomy:
|
| 30 |
+
def suggest(self, query, limit=10):
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_ui_contains_dashboard_capture_confirmation_and_export(tmp_path):
|
| 35 |
+
application = WaterleafApplication(
|
| 36 |
+
store=GardenStore(tmp_path / "waterleaf.sqlite3"),
|
| 37 |
+
media_directory=tmp_path / "media",
|
| 38 |
+
export_directory=tmp_path / "exports",
|
| 39 |
+
public_base_url="https://waterleaf.example",
|
| 40 |
+
care=NoopCare(),
|
| 41 |
+
weather=NoopWeather(),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
demo = build_ui(
|
| 45 |
+
application,
|
| 46 |
+
identification=NoopIdentification(),
|
| 47 |
+
taxonomy=NoopTaxonomy(),
|
| 48 |
+
sample_image="assets/sample-lavender.png",
|
| 49 |
+
)
|
| 50 |
+
config_file = demo.get_config_file()
|
| 51 |
+
config = json.dumps(config_file, default=str)
|
| 52 |
+
|
| 53 |
+
assert "Waterleaf" in config
|
| 54 |
+
assert "Add plant" in config
|
| 55 |
+
assert "Analyze photos" in config
|
| 56 |
+
assert "Confirm species" in config
|
| 57 |
+
assert "Generate 30-day calendar" in config
|
| 58 |
+
assert "Sign in with Hugging Face" in config
|
| 59 |
+
assert "Plant photo (required)" in config
|
| 60 |
+
assert "Database match (required)" in config
|
| 61 |
+
assert "Plant nickname (required)" in config
|
| 62 |
+
assert "City (required)" in config
|
| 63 |
+
assert "Watering time (required)" in config
|
| 64 |
+
assert "Custom watering interval (optional)" in config
|
| 65 |
+
assert "Leave blank to use plant care data" in config
|
| 66 |
+
assert "Example: 7" in config
|
| 67 |
+
assert "Garden location" not in config
|
| 68 |
+
assert "Interval override" not in config
|
| 69 |
+
city = next(
|
| 70 |
+
component
|
| 71 |
+
for component in config_file["components"]
|
| 72 |
+
if component.get("props", {}).get("label") == "City (required)"
|
| 73 |
+
)
|
| 74 |
+
assert city["props"]["value"] == "Stockholm, Sweden"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_ui_handlers_are_not_exposed_as_public_api(tmp_path):
|
| 78 |
+
application = WaterleafApplication(
|
| 79 |
+
store=GardenStore(tmp_path / "waterleaf.sqlite3"),
|
| 80 |
+
media_directory=tmp_path / "media",
|
| 81 |
+
export_directory=tmp_path / "exports",
|
| 82 |
+
public_base_url="https://waterleaf.example",
|
| 83 |
+
care=NoopCare(),
|
| 84 |
+
weather=NoopWeather(),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
demo = build_ui(
|
| 88 |
+
application,
|
| 89 |
+
identification=NoopIdentification(),
|
| 90 |
+
taxonomy=NoopTaxonomy(),
|
| 91 |
+
sample_image="assets/sample-lavender.png",
|
| 92 |
+
)
|
| 93 |
+
dependencies = demo.get_config_file()["dependencies"]
|
| 94 |
+
|
| 95 |
+
assert dependencies
|
| 96 |
+
assert all(item["api_visibility"] != "public" for item in dependencies)
|
| 97 |
+
save_dependency = next(
|
| 98 |
+
item for item in dependencies if item["api_name"] == "save"
|
| 99 |
+
)
|
| 100 |
+
assert len(save_dependency["outputs"]) == 2
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_optional_watering_interval_accepts_blank_or_one_to_thirty_days():
|
| 104 |
+
assert _parse_optional_interval("") is None
|
| 105 |
+
assert _parse_optional_interval("7") == 7
|
| 106 |
+
|
| 107 |
+
with pytest.raises(ValueError, match="whole number from 1 to 30"):
|
| 108 |
+
_parse_optional_interval("0")
|
| 109 |
+
with pytest.raises(ValueError, match="whole number from 1 to 30"):
|
| 110 |
+
_parse_optional_interval("weekly")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_candidate_label_shows_common_and_scientific_names():
|
| 114 |
+
candidate = TaxonCandidate(
|
| 115 |
+
taxon_key="2927305",
|
| 116 |
+
scientific_name="Lavandula angustifolia",
|
| 117 |
+
common_name="English lavender",
|
| 118 |
+
confidence=0.8,
|
| 119 |
+
)
|
| 120 |
+
unavailable = candidate.model_copy(
|
| 121 |
+
update={"common_name": "Lavandula angustifolia"}
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
assert _candidate_label(candidate) == (
|
| 125 |
+
"English lavender | Lavandula angustifolia | 80%"
|
| 126 |
+
)
|
| 127 |
+
assert _candidate_label(unavailable) == (
|
| 128 |
+
"Lavandula angustifolia | Common name unavailable | 80%"
|
| 129 |
+
)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
waterleaf/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Waterleaf application package."""
|
| 2 |
+
|
waterleaf/application.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from datetime import date, time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Protocol
|
| 7 |
+
|
| 8 |
+
from waterleaf.calendar import CalendarPlant, build_garden_ics
|
| 9 |
+
from waterleaf.images import normalize_plant_image
|
| 10 |
+
from waterleaf.models import (
|
| 11 |
+
CareBenchmark,
|
| 12 |
+
CareProfile,
|
| 13 |
+
LocationMatch,
|
| 14 |
+
PlantContext,
|
| 15 |
+
PlantDraft,
|
| 16 |
+
SavedPlant,
|
| 17 |
+
SchedulePlan,
|
| 18 |
+
TaxonCandidate,
|
| 19 |
+
WateringEvent,
|
| 20 |
+
WeatherDay,
|
| 21 |
+
)
|
| 22 |
+
from waterleaf.scheduling import build_watering_schedule
|
| 23 |
+
from waterleaf.storage import GardenStore
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class CareService(Protocol):
|
| 27 |
+
def get_care(self, scientific_name: str) -> CareProfile: ...
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class WeatherService(Protocol):
|
| 31 |
+
def geocode(self, query: str) -> LocationMatch: ...
|
| 32 |
+
|
| 33 |
+
def forecast(self, latitude: float, longitude: float) -> list[WeatherDay]: ...
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class WaterleafApplication:
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
*,
|
| 40 |
+
store: GardenStore,
|
| 41 |
+
media_directory: str | Path,
|
| 42 |
+
export_directory: str | Path,
|
| 43 |
+
public_base_url: str,
|
| 44 |
+
care: CareService,
|
| 45 |
+
weather: WeatherService,
|
| 46 |
+
identification: object | None = None,
|
| 47 |
+
taxonomy: object | None = None,
|
| 48 |
+
):
|
| 49 |
+
self.store = store
|
| 50 |
+
self.media_directory = Path(media_directory)
|
| 51 |
+
self.export_directory = Path(export_directory)
|
| 52 |
+
self.public_base_url = public_base_url.rstrip("/")
|
| 53 |
+
self.care = care
|
| 54 |
+
self.weather = weather
|
| 55 |
+
self.identification = identification
|
| 56 |
+
self.taxonomy = taxonomy
|
| 57 |
+
self.media_directory.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
self.export_directory.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
def preview_schedule(
|
| 61 |
+
self,
|
| 62 |
+
*,
|
| 63 |
+
candidate: TaxonCandidate,
|
| 64 |
+
location_query: str,
|
| 65 |
+
is_container: bool,
|
| 66 |
+
size_label: str,
|
| 67 |
+
start: date | None = None,
|
| 68 |
+
manual_interval_days: int | None = None,
|
| 69 |
+
) -> SchedulePlan:
|
| 70 |
+
location = self.weather.geocode(location_query)
|
| 71 |
+
care = self.care.get_care(candidate.scientific_name)
|
| 72 |
+
if manual_interval_days:
|
| 73 |
+
min_days = max(1, int(manual_interval_days))
|
| 74 |
+
max_days = min_days
|
| 75 |
+
else:
|
| 76 |
+
min_days = care.min_days
|
| 77 |
+
max_days = care.max_days
|
| 78 |
+
weather = self.weather.forecast(location.latitude, location.longitude)
|
| 79 |
+
events = build_watering_schedule(
|
| 80 |
+
start=start or date.today(),
|
| 81 |
+
days=30,
|
| 82 |
+
care=CareBenchmark(min_days=min_days, max_days=max_days),
|
| 83 |
+
context=PlantContext(is_container=is_container, size_label=size_label),
|
| 84 |
+
weather=weather,
|
| 85 |
+
)
|
| 86 |
+
return SchedulePlan(
|
| 87 |
+
location=location,
|
| 88 |
+
care=care,
|
| 89 |
+
events=events,
|
| 90 |
+
is_container=is_container,
|
| 91 |
+
size_label=size_label,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def save_plant(
|
| 95 |
+
self,
|
| 96 |
+
*,
|
| 97 |
+
owner: str,
|
| 98 |
+
nickname: str,
|
| 99 |
+
candidate: TaxonCandidate,
|
| 100 |
+
source_image: str | Path,
|
| 101 |
+
preferred_time: time,
|
| 102 |
+
plan: SchedulePlan,
|
| 103 |
+
edited_events: list[WateringEvent] | None = None,
|
| 104 |
+
) -> SavedPlant:
|
| 105 |
+
image = normalize_plant_image(source_image, self.media_directory)
|
| 106 |
+
plant = self.store.save_plant(
|
| 107 |
+
owner,
|
| 108 |
+
PlantDraft(
|
| 109 |
+
nickname=nickname,
|
| 110 |
+
common_name=candidate.common_name,
|
| 111 |
+
scientific_name=candidate.scientific_name,
|
| 112 |
+
taxon_key=candidate.taxon_key,
|
| 113 |
+
location_name=plan.location.display_name,
|
| 114 |
+
latitude=plan.location.latitude,
|
| 115 |
+
longitude=plan.location.longitude,
|
| 116 |
+
timezone=plan.location.timezone,
|
| 117 |
+
preferred_time=preferred_time,
|
| 118 |
+
is_container=plan.is_container,
|
| 119 |
+
size_label=plan.size_label,
|
| 120 |
+
image_id=image.id,
|
| 121 |
+
care_min_days=plan.care.min_days,
|
| 122 |
+
care_max_days=plan.care.max_days,
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
events = edited_events if edited_events is not None else plan.events
|
| 126 |
+
self.store.replace_schedule(
|
| 127 |
+
owner,
|
| 128 |
+
plant.id,
|
| 129 |
+
[
|
| 130 |
+
{
|
| 131 |
+
"date": item.date.isoformat(),
|
| 132 |
+
"reason": item.reason,
|
| 133 |
+
"confidence": item.confidence,
|
| 134 |
+
}
|
| 135 |
+
for item in events
|
| 136 |
+
],
|
| 137 |
+
)
|
| 138 |
+
return plant
|
| 139 |
+
|
| 140 |
+
def delete_plant(self, owner: str, plant_id: str) -> bool:
|
| 141 |
+
plant = self.store.get_plant(owner, plant_id)
|
| 142 |
+
if plant is None or not self.store.delete_plant(owner, plant_id):
|
| 143 |
+
return False
|
| 144 |
+
if self.store.image_reference_count(plant.image_id) == 0:
|
| 145 |
+
(self.media_directory / f"{plant.image_id}.jpg").unlink(missing_ok=True)
|
| 146 |
+
return True
|
| 147 |
+
|
| 148 |
+
def export_garden(
|
| 149 |
+
self,
|
| 150 |
+
owner: str,
|
| 151 |
+
*,
|
| 152 |
+
generated_at: str | None = None,
|
| 153 |
+
) -> Path:
|
| 154 |
+
calendar_plants: list[CalendarPlant] = []
|
| 155 |
+
for plant in self.store.list_plants(owner):
|
| 156 |
+
events = [
|
| 157 |
+
WateringEvent(
|
| 158 |
+
date=date.fromisoformat(item["date"]),
|
| 159 |
+
reason=item["reason"],
|
| 160 |
+
confidence=item["confidence"],
|
| 161 |
+
)
|
| 162 |
+
for item in self.store.get_schedule(owner, plant.id)
|
| 163 |
+
]
|
| 164 |
+
calendar_plants.append(
|
| 165 |
+
CalendarPlant(
|
| 166 |
+
id=plant.id,
|
| 167 |
+
nickname=plant.nickname,
|
| 168 |
+
common_name=plant.common_name,
|
| 169 |
+
scientific_name=plant.scientific_name,
|
| 170 |
+
timezone=plant.timezone,
|
| 171 |
+
preferred_time=plant.preferred_time,
|
| 172 |
+
profile_url=f"{self.public_base_url}/plants/{plant.public_slug}",
|
| 173 |
+
image_url=f"{self.public_base_url}/media/{plant.image_id}.jpg",
|
| 174 |
+
events=events,
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
if not calendar_plants:
|
| 178 |
+
raise ValueError("Add at least one plant before exporting")
|
| 179 |
+
owner_token = hashlib.sha256(owner.encode()).hexdigest()[:12]
|
| 180 |
+
destination = self.export_directory / f"waterleaf-{owner_token}.ics"
|
| 181 |
+
destination.write_text(
|
| 182 |
+
build_garden_ics(calendar_plants, generated_at=generated_at),
|
| 183 |
+
newline="",
|
| 184 |
+
)
|
| 185 |
+
return destination
|
waterleaf/calendar.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from datetime import datetime, time, timedelta
|
| 6 |
+
|
| 7 |
+
from waterleaf.models import WateringEvent
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class CalendarPlant:
|
| 12 |
+
id: str
|
| 13 |
+
nickname: str
|
| 14 |
+
common_name: str
|
| 15 |
+
scientific_name: str
|
| 16 |
+
timezone: str
|
| 17 |
+
preferred_time: time
|
| 18 |
+
profile_url: str
|
| 19 |
+
image_url: str
|
| 20 |
+
events: list[WateringEvent]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_garden_ics(
|
| 24 |
+
plants: list[CalendarPlant],
|
| 25 |
+
*,
|
| 26 |
+
generated_at: str | None = None,
|
| 27 |
+
) -> str:
|
| 28 |
+
stamp = generated_at or datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
| 29 |
+
lines = [
|
| 30 |
+
"BEGIN:VCALENDAR",
|
| 31 |
+
"VERSION:2.0",
|
| 32 |
+
"PRODID:-//Waterleaf//Garden Watering Calendar//EN",
|
| 33 |
+
"CALSCALE:GREGORIAN",
|
| 34 |
+
"METHOD:PUBLISH",
|
| 35 |
+
"X-WR-CALNAME:Waterleaf",
|
| 36 |
+
]
|
| 37 |
+
for plant in plants:
|
| 38 |
+
for event in plant.events:
|
| 39 |
+
lines.extend(_event_lines(plant, event, stamp))
|
| 40 |
+
lines.append("END:VCALENDAR")
|
| 41 |
+
return "\r\n".join(_fold_line(line) for line in lines) + "\r\n"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _event_lines(
|
| 45 |
+
plant: CalendarPlant,
|
| 46 |
+
event: WateringEvent,
|
| 47 |
+
stamp: str,
|
| 48 |
+
) -> list[str]:
|
| 49 |
+
start_dt = datetime.combine(event.date, plant.preferred_time)
|
| 50 |
+
end_dt = start_dt + timedelta(minutes=15)
|
| 51 |
+
uid_seed = f"{plant.id}:{event.date.isoformat()}".encode()
|
| 52 |
+
uid = hashlib.sha256(uid_seed).hexdigest()[:24]
|
| 53 |
+
description = (
|
| 54 |
+
f"{plant.common_name} ({plant.scientific_name})\n"
|
| 55 |
+
f"{event.reason}\n"
|
| 56 |
+
f"Plant profile: {plant.profile_url}"
|
| 57 |
+
)
|
| 58 |
+
return [
|
| 59 |
+
"BEGIN:VEVENT",
|
| 60 |
+
f"UID:{uid}@waterleaf",
|
| 61 |
+
f"DTSTAMP:{stamp}",
|
| 62 |
+
f"DTSTART;TZID={plant.timezone}:{start_dt:%Y%m%dT%H%M%S}",
|
| 63 |
+
f"DTEND;TZID={plant.timezone}:{end_dt:%Y%m%dT%H%M%S}",
|
| 64 |
+
f"SUMMARY:{_escape_text(f'Water {plant.nickname}')}",
|
| 65 |
+
f"DESCRIPTION:{_escape_text(description)}",
|
| 66 |
+
f"URL:{plant.profile_url}",
|
| 67 |
+
f"ATTACH;FMTTYPE=image/jpeg:{plant.image_url}",
|
| 68 |
+
"BEGIN:VALARM",
|
| 69 |
+
"ACTION:DISPLAY",
|
| 70 |
+
f"DESCRIPTION:{_escape_text(f'Water {plant.nickname}')}",
|
| 71 |
+
"TRIGGER:-PT30M",
|
| 72 |
+
"END:VALARM",
|
| 73 |
+
"END:VEVENT",
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _escape_text(value: str) -> str:
|
| 78 |
+
return (
|
| 79 |
+
value.replace("\\", "\\\\")
|
| 80 |
+
.replace("\r\n", "\n")
|
| 81 |
+
.replace("\r", "\n")
|
| 82 |
+
.replace("\n", "\\n")
|
| 83 |
+
.replace(";", "\\;")
|
| 84 |
+
.replace(",", "\\,")
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _fold_line(line: str, limit: int = 75) -> str:
|
| 89 |
+
encoded = line.encode("utf-8")
|
| 90 |
+
if len(encoded) <= limit:
|
| 91 |
+
return line
|
| 92 |
+
|
| 93 |
+
chunks: list[str] = []
|
| 94 |
+
remaining = line
|
| 95 |
+
first = True
|
| 96 |
+
while remaining:
|
| 97 |
+
available = limit if first else limit - 1
|
| 98 |
+
byte_count = 0
|
| 99 |
+
split_at = 0
|
| 100 |
+
for char in remaining:
|
| 101 |
+
char_length = len(char.encode("utf-8"))
|
| 102 |
+
if byte_count + char_length > available:
|
| 103 |
+
break
|
| 104 |
+
byte_count += char_length
|
| 105 |
+
split_at += 1
|
| 106 |
+
if split_at == 0:
|
| 107 |
+
split_at = 1
|
| 108 |
+
|
| 109 |
+
prefix = "" if first else " "
|
| 110 |
+
chunks.append(prefix + remaining[:split_at])
|
| 111 |
+
remaining = remaining[split_at:]
|
| 112 |
+
first = False
|
| 113 |
+
return "\r\n".join(chunks)
|
waterleaf/evaluation.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Sequence
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def score_predictions(rows: Sequence[dict]) -> dict[str, float | int]:
|
| 7 |
+
count = len(rows)
|
| 8 |
+
if count == 0:
|
| 9 |
+
return {
|
| 10 |
+
"count": 0,
|
| 11 |
+
"species_top_1": 0.0,
|
| 12 |
+
"species_top_3": 0.0,
|
| 13 |
+
"genus_top_1": 0.0,
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
species_top_1 = 0
|
| 17 |
+
species_top_3 = 0
|
| 18 |
+
genus_top_1 = 0
|
| 19 |
+
for row in rows:
|
| 20 |
+
expected = _normalize(row["expected"])
|
| 21 |
+
predictions = [_normalize(value) for value in row.get("predictions", [])]
|
| 22 |
+
if predictions and predictions[0] == expected:
|
| 23 |
+
species_top_1 += 1
|
| 24 |
+
if expected in predictions[:3]:
|
| 25 |
+
species_top_3 += 1
|
| 26 |
+
if predictions and _genus(predictions[0]) == _genus(expected):
|
| 27 |
+
genus_top_1 += 1
|
| 28 |
+
|
| 29 |
+
return {
|
| 30 |
+
"count": count,
|
| 31 |
+
"species_top_1": species_top_1 / count,
|
| 32 |
+
"species_top_3": species_top_3 / count,
|
| 33 |
+
"genus_top_1": genus_top_1 / count,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _normalize(value: str) -> str:
|
| 38 |
+
return " ".join(value.casefold().split())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _genus(scientific_name: str) -> str:
|
| 42 |
+
return scientific_name.split(" ", maxsplit=1)[0]
|
| 43 |
+
|
waterleaf/images.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from PIL import Image, ImageOps
|
| 9 |
+
|
| 10 |
+
MAX_IMAGE_EDGE = 1600
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class StoredImage:
|
| 15 |
+
id: str
|
| 16 |
+
path: Path
|
| 17 |
+
width: int
|
| 18 |
+
height: int
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def normalize_plant_image(
|
| 22 |
+
source_path: str | Path,
|
| 23 |
+
media_directory: str | Path,
|
| 24 |
+
) -> StoredImage:
|
| 25 |
+
media_path = Path(media_directory)
|
| 26 |
+
media_path.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
with Image.open(source_path) as source:
|
| 29 |
+
normalized = ImageOps.exif_transpose(source).convert("RGB")
|
| 30 |
+
normalized.thumbnail((MAX_IMAGE_EDGE, MAX_IMAGE_EDGE), Image.Resampling.LANCZOS)
|
| 31 |
+
width, height = normalized.size
|
| 32 |
+
buffer = io.BytesIO()
|
| 33 |
+
normalized.save(buffer, format="JPEG", quality=88, optimize=True)
|
| 34 |
+
|
| 35 |
+
content = buffer.getvalue()
|
| 36 |
+
image_id = hashlib.sha256(content).hexdigest()[:32]
|
| 37 |
+
destination = media_path / f"{image_id}.jpg"
|
| 38 |
+
if not destination.exists():
|
| 39 |
+
destination.write_bytes(content)
|
| 40 |
+
return StoredImage(id=image_id, path=destination, width=width, height=height)
|
| 41 |
+
|
waterleaf/models.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import date, time
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass(frozen=True)
|
| 10 |
+
class CareBenchmark:
|
| 11 |
+
min_days: int | None
|
| 12 |
+
max_days: int | None
|
| 13 |
+
|
| 14 |
+
@property
|
| 15 |
+
def interval_days(self) -> int:
|
| 16 |
+
if self.min_days is None or self.max_days is None:
|
| 17 |
+
raise ValueError("A watering interval is required")
|
| 18 |
+
if self.min_days < 1 or self.max_days < self.min_days:
|
| 19 |
+
raise ValueError("Watering interval must be positive and ordered")
|
| 20 |
+
return round((self.min_days + self.max_days) / 2)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class PlantContext:
|
| 25 |
+
is_container: bool
|
| 26 |
+
size_label: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class WeatherDay:
|
| 31 |
+
date: date
|
| 32 |
+
precipitation_mm: float
|
| 33 |
+
max_temperature_c: float
|
| 34 |
+
et0_mm: float
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class WateringEvent:
|
| 39 |
+
date: date
|
| 40 |
+
reason: str
|
| 41 |
+
confidence: str
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass(frozen=True)
|
| 45 |
+
class PlantDraft:
|
| 46 |
+
nickname: str
|
| 47 |
+
common_name: str
|
| 48 |
+
scientific_name: str
|
| 49 |
+
taxon_key: str
|
| 50 |
+
location_name: str
|
| 51 |
+
latitude: float
|
| 52 |
+
longitude: float
|
| 53 |
+
timezone: str
|
| 54 |
+
preferred_time: time
|
| 55 |
+
is_container: bool
|
| 56 |
+
size_label: str
|
| 57 |
+
image_id: str
|
| 58 |
+
care_min_days: int | None
|
| 59 |
+
care_max_days: int | None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class SavedPlant:
|
| 64 |
+
id: str
|
| 65 |
+
owner: str
|
| 66 |
+
public_slug: str
|
| 67 |
+
nickname: str
|
| 68 |
+
common_name: str
|
| 69 |
+
scientific_name: str
|
| 70 |
+
taxon_key: str
|
| 71 |
+
location_name: str
|
| 72 |
+
latitude: float
|
| 73 |
+
longitude: float
|
| 74 |
+
timezone: str
|
| 75 |
+
preferred_time: time
|
| 76 |
+
is_container: bool
|
| 77 |
+
size_label: str
|
| 78 |
+
image_id: str
|
| 79 |
+
care_min_days: int | None
|
| 80 |
+
care_max_days: int | None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class PublicPlant:
|
| 85 |
+
public_slug: str
|
| 86 |
+
nickname: str
|
| 87 |
+
common_name: str
|
| 88 |
+
scientific_name: str
|
| 89 |
+
image_id: str
|
| 90 |
+
is_container: bool
|
| 91 |
+
size_label: str
|
| 92 |
+
care_min_days: int | None
|
| 93 |
+
care_max_days: int | None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class TaxonCandidate(BaseModel):
|
| 97 |
+
taxon_key: str
|
| 98 |
+
scientific_name: str
|
| 99 |
+
common_name: str
|
| 100 |
+
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 101 |
+
rationale: str = ""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class VisualAnalysis(BaseModel):
|
| 105 |
+
traits: list[str]
|
| 106 |
+
proposed_names: list[str]
|
| 107 |
+
is_container: bool
|
| 108 |
+
size_label: str
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class IdentificationResult(BaseModel):
|
| 112 |
+
visual: VisualAnalysis
|
| 113 |
+
candidates: list[TaxonCandidate]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class CareProfile(BaseModel):
|
| 117 |
+
scientific_name: str
|
| 118 |
+
common_name: str
|
| 119 |
+
min_days: int | None = None
|
| 120 |
+
max_days: int | None = None
|
| 121 |
+
watering_label: str = ""
|
| 122 |
+
sunlight: list[str] = []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class LocationMatch(BaseModel):
|
| 126 |
+
display_name: str
|
| 127 |
+
latitude: float
|
| 128 |
+
longitude: float
|
| 129 |
+
timezone: str
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@dataclass(frozen=True)
|
| 133 |
+
class SchedulePlan:
|
| 134 |
+
location: LocationMatch
|
| 135 |
+
care: CareProfile
|
| 136 |
+
events: list[WateringEvent]
|
| 137 |
+
is_container: bool
|
| 138 |
+
size_label: str
|
waterleaf/rate_limit.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from collections import defaultdict, deque
|
| 5 |
+
from threading import Lock
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SlidingWindowRateLimiter:
|
| 9 |
+
def __init__(self, *, limit: int, window_seconds: float):
|
| 10 |
+
self.limit = limit
|
| 11 |
+
self.window_seconds = window_seconds
|
| 12 |
+
self._attempts: dict[str, deque[float]] = defaultdict(deque)
|
| 13 |
+
self._lock = Lock()
|
| 14 |
+
|
| 15 |
+
def allow(self, key: str, *, now: float | None = None) -> bool:
|
| 16 |
+
current = time.monotonic() if now is None else now
|
| 17 |
+
cutoff = current - self.window_seconds
|
| 18 |
+
with self._lock:
|
| 19 |
+
attempts = self._attempts[key]
|
| 20 |
+
while attempts and attempts[0] <= cutoff:
|
| 21 |
+
attempts.popleft()
|
| 22 |
+
if len(attempts) >= self.limit:
|
| 23 |
+
return False
|
| 24 |
+
attempts.append(current)
|
| 25 |
+
return True
|
| 26 |
+
|
waterleaf/runtime.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from waterleaf.application import WaterleafApplication
|
| 4 |
+
from waterleaf.services.demo import DemoTaxonomy, build_demo_identification
|
| 5 |
+
from waterleaf.services.gbif import GbifClient
|
| 6 |
+
from waterleaf.services.identification import IdentificationService
|
| 7 |
+
from waterleaf.services.llama_cpp import LlamaCppClient
|
| 8 |
+
from waterleaf.services.open_meteo import OpenMeteoClient
|
| 9 |
+
from waterleaf.services.perenual import PerenualClient
|
| 10 |
+
from waterleaf.settings import Settings
|
| 11 |
+
from waterleaf.storage import GardenStore
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_application(settings: Settings | None = None) -> WaterleafApplication:
|
| 15 |
+
settings = settings or Settings.from_env()
|
| 16 |
+
settings.data_directory.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
if settings.modal_endpoint:
|
| 19 |
+
taxonomy = GbifClient()
|
| 20 |
+
identification = IdentificationService(
|
| 21 |
+
vision=LlamaCppClient(
|
| 22 |
+
endpoint=settings.modal_endpoint,
|
| 23 |
+
modal_key=settings.modal_key,
|
| 24 |
+
modal_secret=settings.modal_secret,
|
| 25 |
+
),
|
| 26 |
+
taxonomy=taxonomy,
|
| 27 |
+
)
|
| 28 |
+
else:
|
| 29 |
+
taxonomy = DemoTaxonomy()
|
| 30 |
+
identification = build_demo_identification()
|
| 31 |
+
|
| 32 |
+
return WaterleafApplication(
|
| 33 |
+
store=GardenStore(settings.database_path),
|
| 34 |
+
media_directory=settings.media_directory,
|
| 35 |
+
export_directory=settings.export_directory,
|
| 36 |
+
public_base_url=settings.public_base_url,
|
| 37 |
+
care=PerenualClient(
|
| 38 |
+
api_key=settings.perenual_api_key,
|
| 39 |
+
cache_path=settings.care_cache_path,
|
| 40 |
+
),
|
| 41 |
+
weather=OpenMeteoClient(),
|
| 42 |
+
identification=identification,
|
| 43 |
+
taxonomy=taxonomy,
|
| 44 |
+
)
|
| 45 |
+
|
waterleaf/scheduling.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date, timedelta
|
| 4 |
+
|
| 5 |
+
from waterleaf.models import CareBenchmark, PlantContext, WateringEvent, WeatherDay
|
| 6 |
+
|
| 7 |
+
FORECAST_WINDOW_DAYS = 16
|
| 8 |
+
CONTAINER_INTERVAL_FACTOR = 0.75
|
| 9 |
+
RAIN_THRESHOLD_MM = 5.0
|
| 10 |
+
HOT_TEMPERATURE_C = 30.0
|
| 11 |
+
HIGH_ET0_MM = 5.0
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_watering_schedule(
|
| 15 |
+
*,
|
| 16 |
+
start: date,
|
| 17 |
+
days: int,
|
| 18 |
+
care: CareBenchmark,
|
| 19 |
+
context: PlantContext,
|
| 20 |
+
weather: list[WeatherDay],
|
| 21 |
+
) -> list[WateringEvent]:
|
| 22 |
+
if days < 1:
|
| 23 |
+
return []
|
| 24 |
+
|
| 25 |
+
interval = care.interval_days
|
| 26 |
+
if context.is_container:
|
| 27 |
+
interval = max(1, round(interval * CONTAINER_INTERVAL_FACTOR))
|
| 28 |
+
|
| 29 |
+
weather_by_date = {item.date: item for item in weather}
|
| 30 |
+
horizon = start + timedelta(days=days)
|
| 31 |
+
candidate = start + timedelta(days=interval)
|
| 32 |
+
events: list[WateringEvent] = []
|
| 33 |
+
|
| 34 |
+
while candidate <= horizon:
|
| 35 |
+
event_date, reason, confidence = _adjust_candidate(
|
| 36 |
+
start=start,
|
| 37 |
+
candidate=candidate,
|
| 38 |
+
weather=weather_by_date,
|
| 39 |
+
)
|
| 40 |
+
if event_date > horizon:
|
| 41 |
+
break
|
| 42 |
+
if not events or event_date > events[-1].date:
|
| 43 |
+
events.append(
|
| 44 |
+
WateringEvent(
|
| 45 |
+
date=event_date,
|
| 46 |
+
reason=reason,
|
| 47 |
+
confidence=confidence,
|
| 48 |
+
)
|
| 49 |
+
)
|
| 50 |
+
candidate = event_date + timedelta(days=interval)
|
| 51 |
+
|
| 52 |
+
return events
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _adjust_candidate(
|
| 56 |
+
*,
|
| 57 |
+
start: date,
|
| 58 |
+
candidate: date,
|
| 59 |
+
weather: dict[date, WeatherDay],
|
| 60 |
+
) -> tuple[date, str, str]:
|
| 61 |
+
day_number = (candidate - start).days
|
| 62 |
+
if day_number > FORECAST_WINDOW_DAYS:
|
| 63 |
+
return candidate, "Seasonal care baseline", "seasonal"
|
| 64 |
+
|
| 65 |
+
nearby = [
|
| 66 |
+
weather.get(candidate - timedelta(days=1)),
|
| 67 |
+
weather.get(candidate),
|
| 68 |
+
]
|
| 69 |
+
if any(item and item.precipitation_mm >= RAIN_THRESHOLD_MM for item in nearby):
|
| 70 |
+
return candidate + timedelta(days=2), "Deferred after forecast rain", "forecast"
|
| 71 |
+
|
| 72 |
+
current = weather.get(candidate)
|
| 73 |
+
if current and (
|
| 74 |
+
current.max_temperature_c >= HOT_TEMPERATURE_C or current.et0_mm >= HIGH_ET0_MM
|
| 75 |
+
):
|
| 76 |
+
return candidate - timedelta(days=1), "Advanced for hot, drying weather", "forecast"
|
| 77 |
+
|
| 78 |
+
return candidate, "Species care baseline", "baseline"
|
| 79 |
+
|
waterleaf/services/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""External service adapters."""
|
| 2 |
+
|
waterleaf/services/demo.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from waterleaf.models import TaxonCandidate, VisualAnalysis
|
| 6 |
+
from waterleaf.services.identification import IdentificationService
|
| 7 |
+
|
| 8 |
+
DEMO_TAXA = [
|
| 9 |
+
TaxonCandidate(
|
| 10 |
+
taxon_key="2925518",
|
| 11 |
+
scientific_name="Lavandula angustifolia",
|
| 12 |
+
common_name="English lavender",
|
| 13 |
+
),
|
| 14 |
+
TaxonCandidate(
|
| 15 |
+
taxon_key="2927009",
|
| 16 |
+
scientific_name="Salvia officinalis",
|
| 17 |
+
common_name="Common sage",
|
| 18 |
+
),
|
| 19 |
+
TaxonCandidate(
|
| 20 |
+
taxon_key="2926017",
|
| 21 |
+
scientific_name="Salvia rosmarinus",
|
| 22 |
+
common_name="Rosemary",
|
| 23 |
+
),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class DemoVision:
|
| 28 |
+
def analyze_images(self, image_paths: list[Path]) -> VisualAnalysis:
|
| 29 |
+
return VisualAnalysis(
|
| 30 |
+
traits=[
|
| 31 |
+
"purple flower spikes",
|
| 32 |
+
"narrow gray-green leaves",
|
| 33 |
+
"woody compact growth",
|
| 34 |
+
],
|
| 35 |
+
proposed_names=[
|
| 36 |
+
"Lavandula angustifolia",
|
| 37 |
+
"Salvia officinalis",
|
| 38 |
+
"Salvia rosmarinus",
|
| 39 |
+
],
|
| 40 |
+
is_container=True,
|
| 41 |
+
size_label="medium",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def rerank(self, image_paths, visual, candidates):
|
| 45 |
+
scores = [0.92, 0.34, 0.18]
|
| 46 |
+
reasons = [
|
| 47 |
+
"Flower spikes and narrow gray-green leaves match.",
|
| 48 |
+
"Leaf color is plausible, but the flower form is weaker.",
|
| 49 |
+
"Woody growth is plausible, but leaf and flower shape differ.",
|
| 50 |
+
]
|
| 51 |
+
return [
|
| 52 |
+
{
|
| 53 |
+
"taxon_key": candidate.taxon_key,
|
| 54 |
+
"confidence": scores[index] if index < len(scores) else 0.1,
|
| 55 |
+
"rationale": reasons[index] if index < len(reasons) else "Weak visual match.",
|
| 56 |
+
}
|
| 57 |
+
for index, candidate in enumerate(candidates)
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class DemoTaxonomy:
|
| 62 |
+
def suggest(self, query: str, limit: int = 10) -> list[TaxonCandidate]:
|
| 63 |
+
needle = query.casefold().strip()
|
| 64 |
+
if not needle:
|
| 65 |
+
return []
|
| 66 |
+
matches = [
|
| 67 |
+
candidate
|
| 68 |
+
for candidate in DEMO_TAXA
|
| 69 |
+
if needle in candidate.common_name.casefold()
|
| 70 |
+
or needle in candidate.scientific_name.casefold()
|
| 71 |
+
]
|
| 72 |
+
return matches[:limit]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_demo_identification() -> IdentificationService:
|
| 76 |
+
return IdentificationService(vision=DemoVision(), taxonomy=DemoTaxonomy())
|
| 77 |
+
|
waterleaf/services/gbif.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from waterleaf.models import TaxonCandidate
|
| 9 |
+
|
| 10 |
+
PLANTAE_KEY = 6
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GbifClient:
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
*,
|
| 17 |
+
http_client: httpx.Client | None = None,
|
| 18 |
+
base_url: str = "https://api.gbif.org/v1",
|
| 19 |
+
):
|
| 20 |
+
self.http_client = http_client or httpx.Client(timeout=15.0)
|
| 21 |
+
self.base_url = base_url.rstrip("/")
|
| 22 |
+
|
| 23 |
+
def suggest(self, query: str, limit: int = 10) -> list[TaxonCandidate]:
|
| 24 |
+
query = query.strip()
|
| 25 |
+
if len(query) < 2:
|
| 26 |
+
return []
|
| 27 |
+
response = self.http_client.get(
|
| 28 |
+
f"{self.base_url}/species/search",
|
| 29 |
+
params={
|
| 30 |
+
"q": query,
|
| 31 |
+
"rank": "SPECIES",
|
| 32 |
+
"highertaxon_key": PLANTAE_KEY,
|
| 33 |
+
"language": "en",
|
| 34 |
+
"limit": min(max(limit * 3, 20), 100),
|
| 35 |
+
},
|
| 36 |
+
headers={"User-Agent": "Waterleaf/0.1 karaoguzh@gmail.com"},
|
| 37 |
+
)
|
| 38 |
+
response.raise_for_status()
|
| 39 |
+
matches: list[tuple[int, TaxonCandidate]] = []
|
| 40 |
+
seen: set[str] = set()
|
| 41 |
+
for item in response.json().get("results", []):
|
| 42 |
+
if item.get("kingdom") != "Plantae" or item.get("rank") != "SPECIES":
|
| 43 |
+
continue
|
| 44 |
+
key = str(item.get("key", ""))
|
| 45 |
+
if not key or key in seen:
|
| 46 |
+
continue
|
| 47 |
+
scientific_name = item.get("canonicalName") or item.get("scientificName")
|
| 48 |
+
if not scientific_name:
|
| 49 |
+
continue
|
| 50 |
+
seen.add(key)
|
| 51 |
+
common_name = _common_name(item, query) or scientific_name
|
| 52 |
+
candidate = TaxonCandidate(
|
| 53 |
+
taxon_key=key,
|
| 54 |
+
scientific_name=scientific_name,
|
| 55 |
+
common_name=common_name,
|
| 56 |
+
)
|
| 57 |
+
matches.append(
|
| 58 |
+
(
|
| 59 |
+
_match_score(
|
| 60 |
+
query=query,
|
| 61 |
+
scientific_name=scientific_name,
|
| 62 |
+
common_name=common_name,
|
| 63 |
+
status=str(item.get("taxonomicStatus", "")),
|
| 64 |
+
),
|
| 65 |
+
candidate,
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
matches.sort(
|
| 69 |
+
key=lambda match: (
|
| 70 |
+
-match[0],
|
| 71 |
+
match[1].common_name.casefold(),
|
| 72 |
+
match[1].scientific_name.casefold(),
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
candidates: list[TaxonCandidate] = []
|
| 76 |
+
seen_scientific_names: set[str] = set()
|
| 77 |
+
for _, candidate in matches:
|
| 78 |
+
scientific_name = candidate.scientific_name.casefold()
|
| 79 |
+
if scientific_name in seen_scientific_names:
|
| 80 |
+
continue
|
| 81 |
+
seen_scientific_names.add(scientific_name)
|
| 82 |
+
candidates.append(candidate)
|
| 83 |
+
if len(candidates) == limit:
|
| 84 |
+
break
|
| 85 |
+
return candidates
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _common_name(item: dict[str, Any], query: str) -> str | None:
|
| 89 |
+
names = [
|
| 90 |
+
str(record.get("vernacularName", "")).strip()
|
| 91 |
+
for record in item.get("vernacularNames", [])
|
| 92 |
+
if str(record.get("language", "")).casefold() in {"en", "eng", "english"}
|
| 93 |
+
and str(record.get("vernacularName", "")).strip()
|
| 94 |
+
]
|
| 95 |
+
direct = str(item.get("vernacularName", "")).strip()
|
| 96 |
+
if direct:
|
| 97 |
+
names.append(direct)
|
| 98 |
+
if not names:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
displays: dict[str, str] = {}
|
| 102 |
+
counts: Counter[str] = Counter()
|
| 103 |
+
for name in names:
|
| 104 |
+
normalized = name.casefold()
|
| 105 |
+
displays.setdefault(normalized, name)
|
| 106 |
+
counts[normalized] += 1
|
| 107 |
+
query_normalized = query.casefold()
|
| 108 |
+
best = min(
|
| 109 |
+
counts,
|
| 110 |
+
key=lambda name: (
|
| 111 |
+
name != query_normalized,
|
| 112 |
+
query_normalized not in name,
|
| 113 |
+
-counts[name],
|
| 114 |
+
len(name),
|
| 115 |
+
name,
|
| 116 |
+
),
|
| 117 |
+
)
|
| 118 |
+
return displays[best]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _match_score(
|
| 122 |
+
*,
|
| 123 |
+
query: str,
|
| 124 |
+
scientific_name: str,
|
| 125 |
+
common_name: str,
|
| 126 |
+
status: str,
|
| 127 |
+
) -> int:
|
| 128 |
+
needle = query.casefold()
|
| 129 |
+
scientific = scientific_name.casefold()
|
| 130 |
+
common = common_name.casefold()
|
| 131 |
+
score = 0
|
| 132 |
+
if common != scientific:
|
| 133 |
+
if common == needle:
|
| 134 |
+
score += 1000
|
| 135 |
+
elif needle in common:
|
| 136 |
+
score += 300
|
| 137 |
+
if scientific == needle:
|
| 138 |
+
score += 900
|
| 139 |
+
elif scientific.startswith(needle):
|
| 140 |
+
score += 250
|
| 141 |
+
if status.casefold() == "accepted":
|
| 142 |
+
score += 100
|
| 143 |
+
return score
|
waterleaf/services/identification.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Protocol
|
| 5 |
+
|
| 6 |
+
from waterleaf.models import IdentificationResult, TaxonCandidate, VisualAnalysis
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class VisionService(Protocol):
|
| 10 |
+
def analyze_images(self, image_paths: list[Path]) -> VisualAnalysis: ...
|
| 11 |
+
|
| 12 |
+
def rerank(
|
| 13 |
+
self,
|
| 14 |
+
image_paths: list[Path],
|
| 15 |
+
visual: VisualAnalysis,
|
| 16 |
+
candidates: list[TaxonCandidate],
|
| 17 |
+
) -> list[dict]: ...
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TaxonomyService(Protocol):
|
| 21 |
+
def suggest(self, query: str, limit: int = 5) -> list[TaxonCandidate]: ...
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class IdentificationService:
|
| 25 |
+
def __init__(self, *, vision: VisionService, taxonomy: TaxonomyService):
|
| 26 |
+
self.vision = vision
|
| 27 |
+
self.taxonomy = taxonomy
|
| 28 |
+
|
| 29 |
+
def identify(self, image_paths: list[Path]) -> IdentificationResult:
|
| 30 |
+
visual = self.vision.analyze_images(image_paths)
|
| 31 |
+
grounded: list[TaxonCandidate] = []
|
| 32 |
+
seen: set[str] = set()
|
| 33 |
+
for name in visual.proposed_names:
|
| 34 |
+
for candidate in self.taxonomy.suggest(name, limit=3):
|
| 35 |
+
if candidate.taxon_key not in seen:
|
| 36 |
+
grounded.append(candidate)
|
| 37 |
+
seen.add(candidate.taxon_key)
|
| 38 |
+
break
|
| 39 |
+
|
| 40 |
+
ranking = self.vision.rerank(image_paths, visual, grounded)
|
| 41 |
+
ranking_by_key = {
|
| 42 |
+
item["taxon_key"]: item for item in ranking if item["taxon_key"] in seen
|
| 43 |
+
}
|
| 44 |
+
candidates: list[TaxonCandidate] = []
|
| 45 |
+
for candidate in grounded:
|
| 46 |
+
scored = ranking_by_key.get(candidate.taxon_key)
|
| 47 |
+
if not scored:
|
| 48 |
+
continue
|
| 49 |
+
candidates.append(
|
| 50 |
+
candidate.model_copy(
|
| 51 |
+
update={
|
| 52 |
+
"confidence": scored["confidence"],
|
| 53 |
+
"rationale": scored["rationale"],
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
)
|
| 57 |
+
candidates.sort(key=lambda item: item.confidence, reverse=True)
|
| 58 |
+
return IdentificationResult(visual=visual, candidates=candidates[:3])
|
| 59 |
+
|
waterleaf/services/llama_cpp.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
from waterleaf.models import TaxonCandidate, VisualAnalysis
|
| 12 |
+
|
| 13 |
+
VISUAL_SCHEMA = {
|
| 14 |
+
"type": "object",
|
| 15 |
+
"properties": {
|
| 16 |
+
"traits": {"type": "array", "items": {"type": "string"}},
|
| 17 |
+
"proposed_names": {"type": "array", "items": {"type": "string"}},
|
| 18 |
+
"is_container": {"type": "boolean"},
|
| 19 |
+
"size_label": {"type": "string", "enum": ["small", "medium", "large"]},
|
| 20 |
+
},
|
| 21 |
+
"required": ["traits", "proposed_names", "is_container", "size_label"],
|
| 22 |
+
"additionalProperties": False,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
RERANK_SCHEMA = {
|
| 26 |
+
"type": "object",
|
| 27 |
+
"properties": {
|
| 28 |
+
"ranking": {
|
| 29 |
+
"type": "array",
|
| 30 |
+
"items": {
|
| 31 |
+
"type": "object",
|
| 32 |
+
"properties": {
|
| 33 |
+
"taxon_key": {"type": "string"},
|
| 34 |
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
| 35 |
+
"rationale": {"type": "string"},
|
| 36 |
+
},
|
| 37 |
+
"required": ["taxon_key", "confidence", "rationale"],
|
| 38 |
+
"additionalProperties": False,
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
},
|
| 42 |
+
"required": ["ranking"],
|
| 43 |
+
"additionalProperties": False,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class LlamaCppClient:
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
*,
|
| 51 |
+
endpoint: str,
|
| 52 |
+
modal_key: str | None = None,
|
| 53 |
+
modal_secret: str | None = None,
|
| 54 |
+
http_client: httpx.Client | None = None,
|
| 55 |
+
max_startup_wait_seconds: float = 120.0,
|
| 56 |
+
):
|
| 57 |
+
self.endpoint = endpoint.rstrip("/")
|
| 58 |
+
self.http_client = http_client or httpx.Client(timeout=180.0)
|
| 59 |
+
self.max_startup_wait_seconds = max_startup_wait_seconds
|
| 60 |
+
self.headers = {"Authorization": "Bearer waterleaf"}
|
| 61 |
+
if modal_key and modal_secret:
|
| 62 |
+
self.headers.update({"Modal-Key": modal_key, "Modal-Secret": modal_secret})
|
| 63 |
+
|
| 64 |
+
def analyze_images(self, image_paths: list[Path]) -> VisualAnalysis:
|
| 65 |
+
content: list[dict[str, Any]] = [
|
| 66 |
+
{
|
| 67 |
+
"type": "text",
|
| 68 |
+
"text": (
|
| 69 |
+
"Identify visible botanical traits and propose up to five likely species. "
|
| 70 |
+
"Infer only visible context: container versus in-ground and rough size."
|
| 71 |
+
),
|
| 72 |
+
}
|
| 73 |
+
]
|
| 74 |
+
content.extend(_image_part(path) for path in image_paths)
|
| 75 |
+
payload = self._completion_payload(
|
| 76 |
+
content,
|
| 77 |
+
schema_name="visual_analysis",
|
| 78 |
+
schema=VISUAL_SCHEMA,
|
| 79 |
+
)
|
| 80 |
+
result = self._post(payload)
|
| 81 |
+
return VisualAnalysis.model_validate(result)
|
| 82 |
+
|
| 83 |
+
def rerank(
|
| 84 |
+
self,
|
| 85 |
+
image_paths: list[Path],
|
| 86 |
+
visual: VisualAnalysis,
|
| 87 |
+
candidates: list[TaxonCandidate],
|
| 88 |
+
) -> list[dict[str, Any]]:
|
| 89 |
+
content: list[dict[str, Any]] = [
|
| 90 |
+
{
|
| 91 |
+
"type": "text",
|
| 92 |
+
"text": (
|
| 93 |
+
"Rank only these database candidates against the images and observed traits. "
|
| 94 |
+
"Do not add species. Candidates: "
|
| 95 |
+
+ json.dumps([item.model_dump() for item in candidates])
|
| 96 |
+
+ " Traits: "
|
| 97 |
+
+ json.dumps(visual.traits)
|
| 98 |
+
),
|
| 99 |
+
}
|
| 100 |
+
]
|
| 101 |
+
content.extend(_image_part(path) for path in image_paths)
|
| 102 |
+
result = self._post(
|
| 103 |
+
self._completion_payload(
|
| 104 |
+
content,
|
| 105 |
+
schema_name="candidate_ranking",
|
| 106 |
+
schema=RERANK_SCHEMA,
|
| 107 |
+
enable_thinking=True,
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
return result["ranking"]
|
| 111 |
+
|
| 112 |
+
def _completion_payload(
|
| 113 |
+
self,
|
| 114 |
+
content: list[dict[str, Any]],
|
| 115 |
+
schema_name: str,
|
| 116 |
+
schema: dict[str, Any],
|
| 117 |
+
enable_thinking: bool = False,
|
| 118 |
+
) -> dict[str, Any]:
|
| 119 |
+
return {
|
| 120 |
+
"model": "waterleaf-gemma-4",
|
| 121 |
+
"messages": [
|
| 122 |
+
{
|
| 123 |
+
"role": "system",
|
| 124 |
+
"content": (
|
| 125 |
+
"You are a cautious plant identification assistant. Return only JSON "
|
| 126 |
+
"matching the requested schema. Never claim certainty from an image."
|
| 127 |
+
),
|
| 128 |
+
},
|
| 129 |
+
{"role": "user", "content": content},
|
| 130 |
+
],
|
| 131 |
+
"temperature": 0.1,
|
| 132 |
+
"max_tokens": 700,
|
| 133 |
+
"chat_template_kwargs": {"enable_thinking": enable_thinking},
|
| 134 |
+
"response_format": {
|
| 135 |
+
"type": "json_schema",
|
| 136 |
+
"json_schema": {
|
| 137 |
+
"name": schema_name,
|
| 138 |
+
"schema": schema,
|
| 139 |
+
"strict": True,
|
| 140 |
+
},
|
| 141 |
+
},
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 145 |
+
deadline = time.monotonic() + self.max_startup_wait_seconds
|
| 146 |
+
while True:
|
| 147 |
+
response = self.http_client.post(
|
| 148 |
+
f"{self.endpoint}/v1/chat/completions",
|
| 149 |
+
headers=self.headers,
|
| 150 |
+
json=payload,
|
| 151 |
+
)
|
| 152 |
+
if response.status_code != 503:
|
| 153 |
+
response.raise_for_status()
|
| 154 |
+
message = response.json()["choices"][0]["message"]["content"]
|
| 155 |
+
return json.loads(message)
|
| 156 |
+
if time.monotonic() >= deadline:
|
| 157 |
+
raise TimeoutError("Modal model did not become ready")
|
| 158 |
+
time.sleep(1)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _image_part(path: Path) -> dict[str, Any]:
|
| 162 |
+
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
| 163 |
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
| 164 |
+
return {
|
| 165 |
+
"type": "image_url",
|
| 166 |
+
"image_url": {"url": f"data:{mime};base64,{encoded}"},
|
| 167 |
+
}
|
waterleaf/services/open_meteo.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
from waterleaf.models import LocationMatch, WeatherDay
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class OpenMeteoClient:
|
| 11 |
+
def __init__(self, *, http_client: httpx.Client | None = None):
|
| 12 |
+
self.http_client = http_client or httpx.Client(timeout=15.0)
|
| 13 |
+
|
| 14 |
+
def geocode(self, query: str) -> LocationMatch:
|
| 15 |
+
response = self.http_client.get(
|
| 16 |
+
"https://geocoding-api.open-meteo.com/v1/search",
|
| 17 |
+
params={"name": query, "count": 1, "language": "en", "format": "json"},
|
| 18 |
+
)
|
| 19 |
+
response.raise_for_status()
|
| 20 |
+
results = response.json().get("results", [])
|
| 21 |
+
if not results:
|
| 22 |
+
raise ValueError(f"Location not found: {query}")
|
| 23 |
+
item = results[0]
|
| 24 |
+
display = ", ".join(
|
| 25 |
+
part for part in [item.get("name"), item.get("admin1"), item.get("country")] if part
|
| 26 |
+
)
|
| 27 |
+
return LocationMatch(
|
| 28 |
+
display_name=display,
|
| 29 |
+
latitude=item["latitude"],
|
| 30 |
+
longitude=item["longitude"],
|
| 31 |
+
timezone=item["timezone"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def forecast(self, latitude: float, longitude: float) -> list[WeatherDay]:
|
| 35 |
+
response = self.http_client.get(
|
| 36 |
+
"https://api.open-meteo.com/v1/forecast",
|
| 37 |
+
params={
|
| 38 |
+
"latitude": latitude,
|
| 39 |
+
"longitude": longitude,
|
| 40 |
+
"daily": (
|
| 41 |
+
"precipitation_sum,temperature_2m_max,"
|
| 42 |
+
"et0_fao_evapotranspiration"
|
| 43 |
+
),
|
| 44 |
+
"forecast_days": 16,
|
| 45 |
+
"timezone": "auto",
|
| 46 |
+
},
|
| 47 |
+
)
|
| 48 |
+
response.raise_for_status()
|
| 49 |
+
daily = response.json()["daily"]
|
| 50 |
+
return [
|
| 51 |
+
WeatherDay(
|
| 52 |
+
date=date.fromisoformat(day),
|
| 53 |
+
precipitation_mm=float(daily["precipitation_sum"][index] or 0),
|
| 54 |
+
max_temperature_c=float(daily["temperature_2m_max"][index] or 0),
|
| 55 |
+
et0_mm=float(daily["et0_fao_evapotranspiration"][index] or 0),
|
| 56 |
+
)
|
| 57 |
+
for index, day in enumerate(daily["time"])
|
| 58 |
+
]
|
| 59 |
+
|
waterleaf/services/perenual.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from waterleaf.models import CareProfile
|
| 9 |
+
|
| 10 |
+
DEMO_CARE = {
|
| 11 |
+
"Lavandula angustifolia": CareProfile(
|
| 12 |
+
scientific_name="Lavandula angustifolia",
|
| 13 |
+
common_name="English lavender",
|
| 14 |
+
min_days=7,
|
| 15 |
+
max_days=10,
|
| 16 |
+
watering_label="Minimum",
|
| 17 |
+
sunlight=["full sun"],
|
| 18 |
+
),
|
| 19 |
+
"Salvia officinalis": CareProfile(
|
| 20 |
+
scientific_name="Salvia officinalis",
|
| 21 |
+
common_name="Common sage",
|
| 22 |
+
min_days=7,
|
| 23 |
+
max_days=10,
|
| 24 |
+
watering_label="Minimum",
|
| 25 |
+
sunlight=["full sun"],
|
| 26 |
+
),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class PerenualClient:
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
*,
|
| 34 |
+
api_key: str | None,
|
| 35 |
+
cache_path: str | Path,
|
| 36 |
+
http_client: httpx.Client | None = None,
|
| 37 |
+
base_url: str = "https://perenual.com/api/v2",
|
| 38 |
+
):
|
| 39 |
+
self.api_key = api_key
|
| 40 |
+
self.cache_path = Path(cache_path)
|
| 41 |
+
self.http_client = http_client or httpx.Client(timeout=20.0)
|
| 42 |
+
self.base_url = base_url.rstrip("/")
|
| 43 |
+
self._cache = self._load_cache()
|
| 44 |
+
|
| 45 |
+
def get_care(self, scientific_name: str) -> CareProfile:
|
| 46 |
+
if scientific_name in self._cache:
|
| 47 |
+
return CareProfile.model_validate(self._cache[scientific_name])
|
| 48 |
+
if not self.api_key:
|
| 49 |
+
profile = DEMO_CARE.get(scientific_name) or CareProfile(
|
| 50 |
+
scientific_name=scientific_name,
|
| 51 |
+
common_name=scientific_name,
|
| 52 |
+
)
|
| 53 |
+
self._remember(scientific_name, profile)
|
| 54 |
+
return profile
|
| 55 |
+
|
| 56 |
+
search = self.http_client.get(
|
| 57 |
+
f"{self.base_url}/species-list",
|
| 58 |
+
params={"key": self.api_key, "q": scientific_name},
|
| 59 |
+
)
|
| 60 |
+
search.raise_for_status()
|
| 61 |
+
entries = search.json().get("data", [])
|
| 62 |
+
if not entries:
|
| 63 |
+
profile = CareProfile(
|
| 64 |
+
scientific_name=scientific_name,
|
| 65 |
+
common_name=scientific_name,
|
| 66 |
+
)
|
| 67 |
+
self._remember(scientific_name, profile)
|
| 68 |
+
return profile
|
| 69 |
+
|
| 70 |
+
details = self.http_client.get(
|
| 71 |
+
f"{self.base_url}/species/details/{entries[0]['id']}",
|
| 72 |
+
params={"key": self.api_key},
|
| 73 |
+
)
|
| 74 |
+
details.raise_for_status()
|
| 75 |
+
payload = details.json()
|
| 76 |
+
min_days, max_days = _parse_benchmark(payload.get("watering_general_benchmark"))
|
| 77 |
+
scientific = payload.get("scientific_name") or [scientific_name]
|
| 78 |
+
profile = CareProfile(
|
| 79 |
+
scientific_name=scientific[0] if isinstance(scientific, list) else scientific,
|
| 80 |
+
common_name=payload.get("common_name") or scientific_name,
|
| 81 |
+
min_days=min_days,
|
| 82 |
+
max_days=max_days,
|
| 83 |
+
watering_label=payload.get("watering") or "",
|
| 84 |
+
sunlight=payload.get("sunlight") or [],
|
| 85 |
+
)
|
| 86 |
+
self._remember(scientific_name, profile)
|
| 87 |
+
return profile
|
| 88 |
+
|
| 89 |
+
def _load_cache(self) -> dict:
|
| 90 |
+
if not self.cache_path.exists():
|
| 91 |
+
return {}
|
| 92 |
+
try:
|
| 93 |
+
return json.loads(self.cache_path.read_text())
|
| 94 |
+
except (json.JSONDecodeError, OSError):
|
| 95 |
+
return {}
|
| 96 |
+
|
| 97 |
+
def _remember(self, key: str, profile: CareProfile) -> None:
|
| 98 |
+
self._cache[key] = profile.model_dump()
|
| 99 |
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
self.cache_path.write_text(json.dumps(self._cache, indent=2, sort_keys=True))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _parse_benchmark(payload: dict | None) -> tuple[int | None, int | None]:
|
| 104 |
+
if not payload or payload.get("unit") != "days":
|
| 105 |
+
return None, None
|
| 106 |
+
value = payload.get("value")
|
| 107 |
+
if isinstance(value, int):
|
| 108 |
+
return value, value
|
| 109 |
+
if isinstance(value, str):
|
| 110 |
+
parts = value.replace(" ", "").split("-")
|
| 111 |
+
try:
|
| 112 |
+
if len(parts) == 1:
|
| 113 |
+
parsed = int(parts[0])
|
| 114 |
+
return parsed, parsed
|
| 115 |
+
return int(parts[0]), int(parts[1])
|
| 116 |
+
except ValueError:
|
| 117 |
+
return None, None
|
| 118 |
+
return None, None
|
| 119 |
+
|
waterleaf/settings.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class Settings:
|
| 10 |
+
data_directory: Path
|
| 11 |
+
public_base_url: str
|
| 12 |
+
perenual_api_key: str | None
|
| 13 |
+
modal_endpoint: str | None
|
| 14 |
+
modal_key: str | None
|
| 15 |
+
modal_secret: str | None
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def database_path(self) -> Path:
|
| 19 |
+
return self.data_directory / "waterleaf.sqlite3"
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def media_directory(self) -> Path:
|
| 23 |
+
return self.data_directory / "media"
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def export_directory(self) -> Path:
|
| 27 |
+
return self.data_directory / "exports"
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def care_cache_path(self) -> Path:
|
| 31 |
+
return self.data_directory / "cache" / "perenual.json"
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def from_env(cls) -> Settings:
|
| 35 |
+
data_directory = Path(os.getenv("WATERLEAF_DATA_DIR", "data"))
|
| 36 |
+
public_base_url = os.getenv("PUBLIC_BASE_URL")
|
| 37 |
+
if not public_base_url:
|
| 38 |
+
space_host = os.getenv("SPACE_HOST")
|
| 39 |
+
public_base_url = (
|
| 40 |
+
f"https://{space_host}" if space_host else "http://localhost:7860"
|
| 41 |
+
)
|
| 42 |
+
return cls(
|
| 43 |
+
data_directory=data_directory,
|
| 44 |
+
public_base_url=public_base_url.rstrip("/"),
|
| 45 |
+
perenual_api_key=os.getenv("PERENUAL_API_KEY"),
|
| 46 |
+
modal_endpoint=os.getenv("MODAL_ENDPOINT"),
|
| 47 |
+
modal_key=os.getenv("MODAL_KEY"),
|
| 48 |
+
modal_secret=os.getenv("MODAL_SECRET"),
|
| 49 |
+
)
|
waterleaf/storage.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import secrets
|
| 5 |
+
import sqlite3
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from waterleaf.models import PlantDraft, PublicPlant, SavedPlant
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class GardenStore:
|
| 15 |
+
def __init__(self, database_path: str | Path):
|
| 16 |
+
self.database_path = Path(database_path)
|
| 17 |
+
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
self._initialize()
|
| 19 |
+
|
| 20 |
+
def _connect(self) -> sqlite3.Connection:
|
| 21 |
+
connection = sqlite3.connect(self.database_path)
|
| 22 |
+
connection.row_factory = sqlite3.Row
|
| 23 |
+
connection.execute("PRAGMA foreign_keys = ON")
|
| 24 |
+
return connection
|
| 25 |
+
|
| 26 |
+
def _initialize(self) -> None:
|
| 27 |
+
with self._connect() as connection:
|
| 28 |
+
connection.executescript(
|
| 29 |
+
"""
|
| 30 |
+
CREATE TABLE IF NOT EXISTS plants (
|
| 31 |
+
id TEXT PRIMARY KEY,
|
| 32 |
+
owner TEXT NOT NULL,
|
| 33 |
+
public_slug TEXT NOT NULL UNIQUE,
|
| 34 |
+
nickname TEXT NOT NULL,
|
| 35 |
+
common_name TEXT NOT NULL,
|
| 36 |
+
scientific_name TEXT NOT NULL,
|
| 37 |
+
taxon_key TEXT NOT NULL,
|
| 38 |
+
location_name TEXT NOT NULL,
|
| 39 |
+
latitude REAL NOT NULL,
|
| 40 |
+
longitude REAL NOT NULL,
|
| 41 |
+
timezone TEXT NOT NULL,
|
| 42 |
+
preferred_time TEXT NOT NULL,
|
| 43 |
+
is_container INTEGER NOT NULL,
|
| 44 |
+
size_label TEXT NOT NULL,
|
| 45 |
+
image_id TEXT NOT NULL,
|
| 46 |
+
care_min_days INTEGER,
|
| 47 |
+
care_max_days INTEGER,
|
| 48 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
| 49 |
+
);
|
| 50 |
+
CREATE INDEX IF NOT EXISTS idx_plants_owner ON plants(owner);
|
| 51 |
+
|
| 52 |
+
CREATE TABLE IF NOT EXISTS schedules (
|
| 53 |
+
plant_id TEXT PRIMARY KEY,
|
| 54 |
+
owner TEXT NOT NULL,
|
| 55 |
+
events_json TEXT NOT NULL,
|
| 56 |
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 57 |
+
FOREIGN KEY (plant_id) REFERENCES plants(id) ON DELETE CASCADE
|
| 58 |
+
);
|
| 59 |
+
"""
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def save_plant(self, owner: str, draft: PlantDraft) -> SavedPlant:
|
| 63 |
+
plant_id = str(uuid.uuid4())
|
| 64 |
+
public_slug = secrets.token_urlsafe(18)
|
| 65 |
+
values = (
|
| 66 |
+
plant_id,
|
| 67 |
+
owner,
|
| 68 |
+
public_slug,
|
| 69 |
+
draft.nickname.strip(),
|
| 70 |
+
draft.common_name.strip(),
|
| 71 |
+
draft.scientific_name.strip(),
|
| 72 |
+
draft.taxon_key,
|
| 73 |
+
draft.location_name.strip(),
|
| 74 |
+
round(draft.latitude, 2),
|
| 75 |
+
round(draft.longitude, 2),
|
| 76 |
+
draft.timezone,
|
| 77 |
+
draft.preferred_time.isoformat(timespec="minutes"),
|
| 78 |
+
int(draft.is_container),
|
| 79 |
+
draft.size_label,
|
| 80 |
+
draft.image_id,
|
| 81 |
+
draft.care_min_days,
|
| 82 |
+
draft.care_max_days,
|
| 83 |
+
)
|
| 84 |
+
with self._connect() as connection:
|
| 85 |
+
connection.execute(
|
| 86 |
+
"""
|
| 87 |
+
INSERT INTO plants (
|
| 88 |
+
id, owner, public_slug, nickname, common_name, scientific_name,
|
| 89 |
+
taxon_key, location_name, latitude, longitude, timezone,
|
| 90 |
+
preferred_time, is_container, size_label, image_id,
|
| 91 |
+
care_min_days, care_max_days
|
| 92 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 93 |
+
""",
|
| 94 |
+
values,
|
| 95 |
+
)
|
| 96 |
+
plant = self.get_plant(owner, plant_id)
|
| 97 |
+
if plant is None:
|
| 98 |
+
raise RuntimeError("Saved plant could not be loaded")
|
| 99 |
+
return plant
|
| 100 |
+
|
| 101 |
+
def list_plants(self, owner: str) -> list[SavedPlant]:
|
| 102 |
+
with self._connect() as connection:
|
| 103 |
+
rows = connection.execute(
|
| 104 |
+
"SELECT * FROM plants WHERE owner = ? ORDER BY created_at, id",
|
| 105 |
+
(owner,),
|
| 106 |
+
).fetchall()
|
| 107 |
+
return [self._plant_from_row(row) for row in rows]
|
| 108 |
+
|
| 109 |
+
def get_plant(self, owner: str, plant_id: str) -> SavedPlant | None:
|
| 110 |
+
with self._connect() as connection:
|
| 111 |
+
row = connection.execute(
|
| 112 |
+
"SELECT * FROM plants WHERE owner = ? AND id = ?",
|
| 113 |
+
(owner, plant_id),
|
| 114 |
+
).fetchone()
|
| 115 |
+
return self._plant_from_row(row) if row else None
|
| 116 |
+
|
| 117 |
+
def get_public_plant(self, public_slug: str) -> PublicPlant | None:
|
| 118 |
+
with self._connect() as connection:
|
| 119 |
+
row = connection.execute(
|
| 120 |
+
"""
|
| 121 |
+
SELECT public_slug, nickname, common_name, scientific_name,
|
| 122 |
+
image_id, is_container, size_label, care_min_days,
|
| 123 |
+
care_max_days
|
| 124 |
+
FROM plants WHERE public_slug = ?
|
| 125 |
+
""",
|
| 126 |
+
(public_slug,),
|
| 127 |
+
).fetchone()
|
| 128 |
+
if not row:
|
| 129 |
+
return None
|
| 130 |
+
return PublicPlant(
|
| 131 |
+
public_slug=row["public_slug"],
|
| 132 |
+
nickname=row["nickname"],
|
| 133 |
+
common_name=row["common_name"],
|
| 134 |
+
scientific_name=row["scientific_name"],
|
| 135 |
+
image_id=row["image_id"],
|
| 136 |
+
is_container=bool(row["is_container"]),
|
| 137 |
+
size_label=row["size_label"],
|
| 138 |
+
care_min_days=row["care_min_days"],
|
| 139 |
+
care_max_days=row["care_max_days"],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def delete_plant(self, owner: str, plant_id: str) -> bool:
|
| 143 |
+
with self._connect() as connection:
|
| 144 |
+
cursor = connection.execute(
|
| 145 |
+
"DELETE FROM plants WHERE owner = ? AND id = ?",
|
| 146 |
+
(owner, plant_id),
|
| 147 |
+
)
|
| 148 |
+
return cursor.rowcount == 1
|
| 149 |
+
|
| 150 |
+
def image_reference_count(self, image_id: str) -> int:
|
| 151 |
+
with self._connect() as connection:
|
| 152 |
+
row = connection.execute(
|
| 153 |
+
"SELECT COUNT(*) AS count FROM plants WHERE image_id = ?",
|
| 154 |
+
(image_id,),
|
| 155 |
+
).fetchone()
|
| 156 |
+
return int(row["count"])
|
| 157 |
+
|
| 158 |
+
def replace_schedule(
|
| 159 |
+
self,
|
| 160 |
+
owner: str,
|
| 161 |
+
plant_id: str,
|
| 162 |
+
events: list[dict[str, Any]],
|
| 163 |
+
) -> None:
|
| 164 |
+
if self.get_plant(owner, plant_id) is None:
|
| 165 |
+
raise PermissionError("Plant not found for owner")
|
| 166 |
+
payload = json.dumps(events, separators=(",", ":"), sort_keys=True)
|
| 167 |
+
with self._connect() as connection:
|
| 168 |
+
connection.execute(
|
| 169 |
+
"""
|
| 170 |
+
INSERT INTO schedules (plant_id, owner, events_json)
|
| 171 |
+
VALUES (?, ?, ?)
|
| 172 |
+
ON CONFLICT(plant_id) DO UPDATE SET
|
| 173 |
+
owner = excluded.owner,
|
| 174 |
+
events_json = excluded.events_json,
|
| 175 |
+
updated_at = CURRENT_TIMESTAMP
|
| 176 |
+
""",
|
| 177 |
+
(plant_id, owner, payload),
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
def get_schedule(self, owner: str, plant_id: str) -> list[dict[str, Any]]:
|
| 181 |
+
with self._connect() as connection:
|
| 182 |
+
row = connection.execute(
|
| 183 |
+
"SELECT events_json FROM schedules WHERE owner = ? AND plant_id = ?",
|
| 184 |
+
(owner, plant_id),
|
| 185 |
+
).fetchone()
|
| 186 |
+
return json.loads(row["events_json"]) if row else []
|
| 187 |
+
|
| 188 |
+
def get_public_schedule(self, public_slug: str) -> list[dict[str, Any]]:
|
| 189 |
+
with self._connect() as connection:
|
| 190 |
+
row = connection.execute(
|
| 191 |
+
"""
|
| 192 |
+
SELECT schedules.events_json
|
| 193 |
+
FROM schedules
|
| 194 |
+
JOIN plants ON plants.id = schedules.plant_id
|
| 195 |
+
WHERE plants.public_slug = ?
|
| 196 |
+
""",
|
| 197 |
+
(public_slug,),
|
| 198 |
+
).fetchone()
|
| 199 |
+
return json.loads(row["events_json"]) if row else []
|
| 200 |
+
|
| 201 |
+
@staticmethod
|
| 202 |
+
def _plant_from_row(row: sqlite3.Row) -> SavedPlant:
|
| 203 |
+
return SavedPlant(
|
| 204 |
+
id=row["id"],
|
| 205 |
+
owner=row["owner"],
|
| 206 |
+
public_slug=row["public_slug"],
|
| 207 |
+
nickname=row["nickname"],
|
| 208 |
+
common_name=row["common_name"],
|
| 209 |
+
scientific_name=row["scientific_name"],
|
| 210 |
+
taxon_key=row["taxon_key"],
|
| 211 |
+
location_name=row["location_name"],
|
| 212 |
+
latitude=row["latitude"],
|
| 213 |
+
longitude=row["longitude"],
|
| 214 |
+
timezone=row["timezone"],
|
| 215 |
+
preferred_time=time.fromisoformat(row["preferred_time"]),
|
| 216 |
+
is_container=bool(row["is_container"]),
|
| 217 |
+
size_label=row["size_label"],
|
| 218 |
+
image_id=row["image_id"],
|
| 219 |
+
care_min_days=row["care_min_days"],
|
| 220 |
+
care_max_days=row["care_max_days"],
|
| 221 |
+
)
|