Native planting recommendations that reason from your conditions, not from the label on your region.
Plant recommendation tools are almost always a hardcoded table keyed on a region name. That breaks the moment your conditions don't match the label: one district holds a dozen microclimates, the same coordinates want different plants in April and July, and a table has no way to say so.
EcoEcho replaces the table with two stages that do different jobs. A vector index decides what is relevant to what you asked for. A structured climate envelope decides what is viable where you are, right now. Neither is sufficient alone — similarity alone happily recommends a tree that needs 2000 mm of rain to someone with 450 mm, and hardiness alone returns neem for every query.
16 native species · 384-dim embeddings · pgvector cosine index · live weather at your coordinates
The obvious build is: embed the species descriptions, embed the user's question, return the nearest neighbours. It demos well and it gives bad advice.
Ask "a flowering tree for my garden" and kadamba scores beautifully — it is a tree, it flowers, the description says so. Kadamba also dies below about 1000 mm of annual rainfall. Similarity has no concept of viability, because viability isn't a property of the text, it's a property of the place.
So the pipeline has a re-ranking stage, and the two stages stay separate on purpose. Folding hardiness into the embedding would make both untestable and neither inspectable.
flowchart TB
Q[user query] --> A[augment with live conditions]
W[Open-Meteo: current temp,<br/>30-day rain, annual normals] --> A
A --> E[embed 384-dim]
E --> V[(pgvector<br/>cosine similarity)]
V --> C[12 candidates]
C --> R[re-rank against<br/>climate envelope]
R --> F[drop below viability floor]
F --> O[ranked recommendations<br/>+ reasons + cautions]
This is the step that makes the same query return different plants in different places. Before embedding, the query is rewritten with the conditions at the user's coordinates, phrased in the vocabulary the corpus uses:
a flowering tree for a small compound
Site conditions: annual rainfall around 450 mm, current temperature 39 °C, summer season, planting in April. This is a dry, low-rainfall site needing drought-tolerant planting. Currently very hot. Little rain has fallen recently, so irrigation would be needed.
That last part matters more than it looks. Similarity is measured between the query and the corpus descriptions, so describing conditions in words the corpus never uses adds tokens and no signal. The corpus says "drought-hardy… low rainfall between 400 and 1200 mm"; the augmented query says "dry, low-rainfall site". They meet.
The app returns the augmented string in a collapsible panel, because being able to read the text that produced a result is most of what makes a retrieval bug findable.
Three live inputs, from two Open-Meteo endpoints (both keyless):
| Input | Source | Why |
|---|---|---|
| Current temperature | forecast endpoint | Whether a species can be planted now |
| 30-day rainfall | forecast endpoint, past_days=30 |
Whether irrigation is needed on arrival |
| Annual rainfall | archive endpoint, trailing 370 days | The axis that decides survival over a year |
"Is it raining today" is nearly useless for a planting decision. "How much rain does this place get in a year" is most of the answer, which is why the archive call exists at all.
Weather failure is not fatal. If either endpoint is unreachable the request still succeeds using coarse latitude/longitude climate estimates and a degraded: true flag that the UI surfaces plainly. A recommendation from climate normals still beats a lookup keyed on a region name.
The single most useful thing I learned building this: retrieval quality tracked how carefully the species descriptions were written, far more than it tracked the embedding model or the value of k. Swapping encoders moved results a little. Rewriting a description to lead with the conditions someone would actually type moved them a lot.
So each description is written for retrieval, front-loading the vocabulary a query is likely to contain, and the corpus is tested like code:
{
"id": "karanja",
"commonName": "Karanja, Indian Beech",
"botanicalName": "Pongamia pinnata",
"climate": { "minTempC": 6, "maxTempC": 46, "minRainfallMm": 500, "maxRainfallMm": 2500 },
"sun": "full sun", "water": "moderate",
"soils": ["sandy", "saline", "coastal", "clay", "waterlogged"],
"plantingMonths": [6, 7],
"ecologicalValue": [
"Handles salt spray and saline groundwater that kill most inland trees",
"Nitrogen-fixing legume that rehabilitates degraded coastal soil"
],
"description": "A salt-tolerant nitrogen-fixing tree for coastal and saline sites — beach-adjacent land, salt-affected fields, backwater edges — across rainfall from 500 to 2500 mm. The right answer near the sea, where salt spray and saline groundwater rule out neem, jamun and most other natives…"
}Note what the description does: it names the situation ("beach-adjacent land, salt-affected fields"), states the numbers, and says explicitly what it beats and why. A description that just said "a medium tree with yellow-white flowers" would be botanically accurate and retrieve on nothing anyone types.
16 species deliberately spanning distinct niches rather than 200 near-duplicates — dry and hot, riparian and flooded, coastal and saline, humid, degraded and eroded, alkaline, and container-scale for someone with only a balcony. Every one is native to the subcontinent, with its ecological role recorded, because the reason to prefer a native over an ornamental is that role.
tests/corpus.test.ts enforces this: unique ids, non-inverted climate ranges, physically plausible bounds, envelopes wide enough to be recommendable, planting months in range, descriptions long enough and mentioning rainfall numerically and light requirements, plus coverage checks (every water demand represented, at least one container-scale option, at least one shade-tolerant option). scripts/seed.ts runs the same invariants and hard-fails rather than writing a corpus with a reversed range.
No model call is involved in ranking. It is arithmetic over the corpus, so the order is deterministic and every line the user reads comes from the same comparison that produced the score.
Band score. How well a value sits in a tolerated range. Inside scores 1; outside it falls off linearly over a tolerance proportional to the band's own width — a species with a 400–1200 mm envelope really is more forgiving at its edges than one with a 1100–1200 mm envelope, so the fall-off can't be a fixed number of millimetres.
Suitability blends three axes:
| Axis | Weight | Why that weight |
|---|---|---|
| Annual rainfall | 0.45 | The axis that actually kills plants over a year |
| Temperature | 0.35 | Bands are wider than they look; most natives tolerate a broad range |
| Planting month | 0.20 | Real but recoverable — plant off-season and you water more, you don't lose it |
Rainfall and temperature are necessary conditions, not contributions to an average. This one was found by a test rather than by reasoning, and it is the most instructive bug in the repo. Kadamba needs 1000 mm; at 200 mm its rainfall band score is zero — it simply cannot live there. But a favourable planting month and a tolerable temperature carried the blended score to 0.49, comfortably clearing the viability floor, and the model cheerfully recommended it.
A weighted average cannot express "this one factor is fatal". So a zero on either hard axis now zeroes the whole score, with an explicit caution, and the soft floor is left to handle species that are merely a poor fit:
survivable = rainfallScore > 0 && temperatureScore > 0
score = survivable ? weighted sum : 0
The boundary falls out of bandScore rather than being a second threshold to tune. Neem's ceiling is 1200 mm with an 800 mm envelope, so its tolerance is 280 mm: at 1300 mm it scores low with a "wetter than it prefers" caution, and at 1500 mm the gate fires.
Final rank is 0.55 × similarity + 0.45 × suitability. Viability sits slightly below relevance deliberately: the user asked for something, and burying their request under a hardiness score returns neem for every query. The viability warning lives in the cautions instead.
Below 0.35 suitability, a species is dropped, not warned about. Recommending something that will die — however relevant it sounded — is worse than returning a shorter list, and an empty result is a real answer.
Reasons and cautions are generated from the same comparisons:
✅ Your estimated 900 mm of annual rainfall sits inside its 600–2500 mm range. ✅ July is a good month to plant it.
⚠️ Wetter than it prefers — 2800 mm against a 1200 mm maximum. Needs genuinely free-draining ground or a raised bed.⚠️ Wants steady moisture, and only 5 mm has fallen in the last 30 days.
The interesting parts of supabase/migrations/0001_species.sql:
vector(384)— the dimension is part of the schema, not a runtime detail. Changing the embedding model means a migration and a re-seed, andembeddings.tsvalidates the dimension on every response so a mismatch fails loudly instead of silently degrading similarity.match_species(query_embedding, match_count, min_similarity, filter_types)— returns1 - (embedding <=> query)so callers get a 0–1 score where higher is better, rather than a distance where lower is better. Structured filters apply before ranking, so a type filter costs nothing in recall.- IVFFlat with
lists = 1— at 16 rows the index scan is not the bottleneck, and over-partitioning a small corpus hurts recall. The comment says to revisit when the corpus grows, because that is a real threshold and not a permanent choice. - Row-level security on both tables. The corpus is public reference data: reads open, writes closed. History is scoped with
user_id = auth.jwt() ->> 'sub', so a user can only ever read their own rows — enforced in the database rather than in application code, which means a bug in a handler cannot leak someone else's history.
Two Supabase clients, kept deliberately apart: the anon client is RLS-bound and used by request handlers; the service client bypasses RLS and is only ever constructed by the seed script. A handler cannot accidentally acquire write access to the corpus.
git clone https://github.com/NavyashreeNS/ecoecho.git
cd ecoecho
npm install
cp .env.example .env # Supabase, Hugging Face, ClerkApply the migration (Supabase SQL editor or supabase db push), then seed:
npm run seed:dry # validate the corpus — no API calls, no writes
npm run seed # embed 16 descriptions and upsert
npm run dev # http://localhost:3000seed:dry exists because corpus validation is the failure you want to catch before spending embedding calls, and it reports description-length statistics so a thin entry is visible immediately.
src/
├── lib/
│ ├── types.ts domain types, mirroring the table and the JSON
│ ├── query.ts query augmentation — pure, no I/O imports
│ ├── scoring.ts band score, suitability, re-ranking — pure
│ ├── weather.ts Open-Meteo, with degradation
│ ├── embeddings.ts Hugging Face, with dimension validation
│ ├── supabase.ts two clients + row mapping
│ └── retrieve.ts the pipeline
├── app/
│ ├── api/recommend/ the one endpoint
│ └── page.tsx
├── components/Finder.tsx
└── middleware.ts Clerk, protecting by explicit matcher
data/species.json the corpus
scripts/seed.ts validate → embed → upsert
supabase/migrations/ schema, RPC, RLS
query.ts and scoring.ts import nothing but types, which is what makes the test suite runnable with no network, no database, and no API keys.
npm test55 tests across three suites, all passing, over the layers where a mistake is invisible at runtime — a bad climate envelope or a silently-dropped qualifier doesn't throw, it just quietly recommends the wrong plant:
corpus— ids unique and slug-safe, enums constrained, climate ranges ordered and physically plausible, envelopes wide enough to be recommendable, height pairs ordered, planting months in range, ecological value recorded, descriptions long enough and mentioning rainfall numerically and light requirements; plus niche coverage (all three water demands, a container-scale option, a shade-tolerant option, dry and wet extremes)query— the IMD four-season split, local month at the user's longitude rather than the server's, user's words kept first, numeric conditions present, qualitative bands added only at the thresholds, irrigation mentioned only when dry outside the monsoon, and the two properties the whole design rests on: same query in different places produces different text, and same place in different seasons produces different textscoring— band score inside, at the edges, outside, and on a degenerate zero-width band; tolerance scaling with envelope width; each caution triggered by its real condition; the hard survivability gate firing on a fatal mismatch and not firing on a merely poor fit; rank ordering by blended score rather than similarity alone; an empty list rather than a bad recommendation; monotonicity in similarity when viability is equal
The survivability gate above exists because a test caught the blended score recommending a species that could not survive the site. That is the argument for testing the scoring layer at all: nothing throws when a recommendation is merely wrong.
Why not put hardiness in the embedding. It would work, badly, and it would be unfixable. Once "needs 500–1500 mm" is a direction in vector space you can no longer test whether the rainfall rule is right, explain why a plant was dropped, or change the weighting without re-embedding the corpus. Keeping viability as arithmetic over columns costs one extra stage and buys testability, explainability, and the ability to tune weights in a pull request.
Why authentication if the corpus is public. Not gatekeeping — each query costs an embedding call, and per-user history is the feature that makes the app worth coming back to. Clerk protects by explicit route matcher rather than protecting everything, so making a page public is a deliberate edit.
Why cautions instead of filtering harder. A plant that needs more water than the site gets is often still the right choice if the person is willing to irrigate. Dropping it would be paternalistic; showing it with "expect to irrigate through the dry months" respects the decision. The hard floor only removes what will not survive at all.
What I'd do next. Soil type and sun exposure are in the corpus but only enter through the description text — they should be structured inputs to the query and additional axes in the suitability score. The corpus wants extending to 60–80 species before the niche coverage is genuinely good, and it should be regionally reviewed rather than desk-researched. Cache embeddings for repeated queries, since the same phrasings will recur. And the honest gap: there is no evaluation set, so "the augmentation improves relevance" is an argument from construction, not a measured result — building a labelled query set is the next real piece of work.
Honest limitations. 16 species is a demonstration corpus, not a planting authority; several niches have exactly one representative, so a filter can empty the list quickly. The annual-rainfall fallback is a coarse four-band latitude/longitude estimate for the subcontinent, flagged as degraded but still crude. Local month is derived from longitude rather than a real timezone database, which is wrong for a few hours a year — inside tolerance for a planting decision, but wrong. Recommendations are ecological and horticultural, not a substitute for local forestry advice.
MIT — see LICENSE.