hkaraoguz commited on
Commit
60e41bb
·
verified ·
1 Parent(s): 1d02209

Deploy Waterleaf challenge app

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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: gray
5
- colorTo: yellow
6
  sdk: docker
7
- pinned: false
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 6c5cf2cfbfa347fa1529d5bfe9707a59ad2314a8c4b22873cf4694017200206e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.87 MB
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
+ )