Elraphaa commited on
Commit
11dfae8
·
verified ·
1 Parent(s): f6f7b53

Generate advice automatically when a check-in is scored

Browse files
Files changed (4) hide show
  1. README.md +12 -5
  2. app.py +48 -0
  3. greenproof_ml/advisor.py +621 -386
  4. requirements.txt +32 -26
README.md CHANGED
@@ -86,17 +86,24 @@ Space → Settings → Variables and secrets:
86
  |---|---|---|
87
  | `SUPABASE_URL` | variable | your project URL |
88
  | `SUPABASE_SERVICE_KEY` | **secret** | the `service_role` key |
89
- | `ANTHROPIC_API_KEY` | **secret** | for `/advise` only omit and advice is simply disabled |
 
 
90
  | `ADVISE_TOKEN` | **secret** | any random string; required in `X-Advise-Token` on `/advise` |
91
  | `ALLOWED_ORIGINS` | variable | your Vercel URL |
92
 
93
  **The service key must never appear in the frontend.** It can write any verdict
94
  for any tree, and it is the value the whole security model rests on.
95
 
96
- **`ANTHROPIC_API_KEY` is billable.** A leak is someone else's spending. Omitting
97
- it is a supported state, not a failure: `/score` is unaffected and `/advise`
98
- returns `{"written": false}` the advisory layer fails soft by design so it can
99
- never take verification down with it.
 
 
 
 
 
100
 
101
  **`ADVISE_TOKEN` matters on a public Space.** Without it, anyone who finds this
102
  URL and a valid check-in id can spend your Anthropic credit three cents at a
 
86
  |---|---|---|
87
  | `SUPABASE_URL` | variable | your project URL |
88
  | `SUPABASE_SERVICE_KEY` | **secret** | the `service_role` key |
89
+ | `GEMINI_API_KEY` | **secret** | for `/advise` — free tier, the default provider |
90
+ | `ANTHROPIC_API_KEY` | **secret** | alternative provider, used only if no Gemini key |
91
+ | `ADVISOR_PROVIDER` | variable | optional: pin to `gemini` or `anthropic` |
92
  | `ADVISE_TOKEN` | **secret** | any random string; required in `X-Advise-Token` on `/advise` |
93
  | `ALLOWED_ORIGINS` | variable | your Vercel URL |
94
 
95
  **The service key must never appear in the frontend.** It can write any verdict
96
  for any tree, and it is the value the whole security model rests on.
97
 
98
+ **Two providers, one interface.** Advice runs on Gemini's free tier by default,
99
+ and falls back to Anthropic if only that key is present. Free tiers have closed
100
+ under this project three times mid-build, so a second provider is the cheapest
101
+ insurance against a fourth and switching is one environment variable, not a
102
+ code change.
103
+
104
+ **Setting neither is a supported state, not a failure:** `/score` is unaffected
105
+ and `/advise` returns `{"written": false}`. The advisory layer fails soft by
106
+ design so it can never take verification down with it.
107
 
108
  **`ADVISE_TOKEN` matters on a public Space.** Without it, anyone who finds this
109
  URL and a valid check-in id can spend your Anthropic credit three cents at a
app.py CHANGED
@@ -106,6 +106,50 @@ def health() -> dict:
106
  }
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  @app.post("/score", response_model=ScoreResponse)
110
  def score(req: ScoreRequest) -> ScoreResponse:
111
  """Score one check-in.
@@ -124,6 +168,9 @@ def score(req: ScoreRequest) -> ScoreResponse:
124
  log.exception("scoring failed for %s", req.checkin_id)
125
  raise HTTPException(500, f"Scoring failed: {e}") from e
126
 
 
 
 
127
  return ScoreResponse(
128
  checkin_id=req.checkin_id,
129
  confidence=result.confidence,
@@ -209,6 +256,7 @@ def backfill(limit: int = 50) -> dict:
209
  for row in rows:
210
  try:
211
  result = score_checkin(row["id"])
 
212
  done.append({"id": row["id"], "verdict": result.verdict, "confidence": result.confidence})
213
  except Exception as e: # noqa: BLE001
214
  log.exception("backfill failed for %s", row["id"])
 
106
  }
107
 
108
 
109
+ # Generate advice automatically whenever a check-in is scored.
110
+ #
111
+ # WHY THIS EXISTS: WITHOUT IT, NOTHING EVER CALLS /advise.
112
+ #
113
+ # The endpoint and the backfill tool were both built, and neither was ever
114
+ # triggered by the act of checking in - so a planter completed a visit, got a
115
+ # verdict, and saw no advice at all unless somebody ran a script by hand
116
+ # afterwards. The feature worked and was invisible, which is the same thing as
117
+ # not working.
118
+ #
119
+ # WHY HERE AND NOT IN THE PIPELINE. `pipeline.py` still does not import the
120
+ # advisor, and must not: that import boundary is what guarantees a slow,
121
+ # rate-limited or hallucinating model can never affect a verdict. So the trigger
122
+ # sits HERE, in the transport layer, and only AFTER score_checkin has returned
123
+ # and the verdict is already committed to the database.
124
+ #
125
+ # Three properties this deliberately preserves:
126
+ #
127
+ # - /score latency is unchanged; the call returns while advice is still
128
+ # running, exactly as before
129
+ # - a failure cannot touch the verdict, because the verdict is already written
130
+ # - scoring stays replayable offline with no external dependency
131
+ ADVISE_ON_SCORE = os.environ.get("ADVISE_ON_SCORE", "1").strip() not in ("0", "false", "no")
132
+
133
+
134
+ def _advise_in_background(checkin_id: str) -> None:
135
+ """Fire and forget. Never raises, never blocks the caller."""
136
+ if not ADVISE_ON_SCORE:
137
+ return
138
+
139
+ def run() -> None:
140
+ try:
141
+ from greenproof_ml import advisor
142
+
143
+ result = advisor.advise_checkin(checkin_id)
144
+ log.info(
145
+ "advice for %s: %s", checkin_id, "written" if result else "nothing written"
146
+ )
147
+ except Exception: # noqa: BLE001 - advisory only, never fatal
148
+ log.exception("background advice failed for %s", checkin_id)
149
+
150
+ threading.Thread(target=run, daemon=True).start()
151
+
152
+
153
  @app.post("/score", response_model=ScoreResponse)
154
  def score(req: ScoreRequest) -> ScoreResponse:
155
  """Score one check-in.
 
168
  log.exception("scoring failed for %s", req.checkin_id)
169
  raise HTTPException(500, f"Scoring failed: {e}") from e
170
 
171
+ # Advice is generated AFTER the verdict is written, in a background thread.
172
+ _advise_in_background(req.checkin_id)
173
+
174
  return ScoreResponse(
175
  checkin_id=req.checkin_id,
176
  confidence=result.confidence,
 
256
  for row in rows:
257
  try:
258
  result = score_checkin(row["id"])
259
+ _advise_in_background(row["id"])
260
  done.append({"id": row["id"], "verdict": result.verdict, "confidence": result.confidence})
261
  except Exception as e: # noqa: BLE001
262
  log.exception("backfill failed for %s", row["id"])
greenproof_ml/advisor.py CHANGED
@@ -1,386 +1,621 @@
1
- """Species identification and care advice for the planter.
2
-
3
- THE ONE THING TO UNDERSTAND ABOUT THIS FILE
4
-
5
- Nothing here is a verification signal, and nothing here may become one.
6
-
7
- The verification engine answers "is this the same tree, alive, in the right
8
- place, not a duplicate" - and it answers it with detectors whose error rate we
9
- have measured and published. This file answers a different question that nobody
10
- has been answering at all: "what is wrong with this tree and what should the
11
- planter do about it?"
12
-
13
- Those two questions deserve different standards of evidence, so they are kept
14
- apart at every level: a separate module THE SCORING PATH NEVER IMPORTS, a
15
- separate endpoint, separate database columns, and a separate place in the UI
16
- that names the model and says the word "advice".
17
-
18
- The import direction is the load-bearing part. `pipeline.py` does not know this
19
- file exists, so there is no code path by which a slow, failed or hallucinated
20
- advisory call can affect a verdict. Check that property still holds before
21
- adding any import to this module's callers:
22
-
23
- grep -rn "advisor" ml/greenproof_ml/pipeline.py ml/greenproof_ml/scoring.py
24
-
25
- should print nothing, permanently.
26
-
27
- WHY AN LLM HERE, WHEN WE REFUSED ONE EVERYWHERE ELSE
28
-
29
- The obvious alternative is a leaf-disease classifier trained on PlantVillage.
30
- We are not doing that, for three reasons and the first is disqualifying:
31
-
32
- 1. PlantVillage is single leaves on uniform lab backgrounds. Published
33
- cross-domain evaluations collapse from ~99% to roughly 30-50% on real
34
- field photographs. We have already made exactly this mistake once and
35
- turned it into our best slide: the plant check scored a perfect AUC of
36
- 1.000 and then flagged five of nine real check-ins as "not a plant",
37
- because it had learned photo STYLE, not subject. Shipping a PlantVillage
38
- classifier would be repeating a mistake we have documented.
39
-
40
- 2. It covers fourteen crops - tomato, potato, corn, grape. None of them are
41
- Odum, Wawa, Ofram, Ceiba, or anything else in a Ghanaian planting scheme.
42
-
43
- 3. We have no ground truth. No agronomist has labelled our trees. This
44
- project's entire claim is that we publish our own measured error rate, and
45
- a classifier whose error rate we cannot measure would be the one component
46
- contradicting that claim.
47
-
48
- An LLM's output is hedged natural language that a person reads and judges, not
49
- a number that authorises a payment. The bar it has to clear is therefore much
50
- lower, and it is honest about clearing a lower bar - which is why `limitations`
51
- below is a required field rather than an optional one.
52
-
53
- FAILS SOFT, ALWAYS. No key, no network, a rate limit, a refusal, a malformed
54
- response: every one of them logs and returns None. The caller writes nothing and
55
- the check-in is untouched. This service being unreachable already costs us
56
- nothing (scoring is asynchronous and replayable); the advisory layer inherits
57
- that property rather than weakening it.
58
- """
59
-
60
- from __future__ import annotations
61
-
62
- import base64
63
- import io
64
- import logging
65
- import os
66
- from typing import Literal
67
-
68
- from PIL import Image
69
- from pydantic import BaseModel, Field
70
-
71
- log = logging.getLogger(__name__)
72
-
73
- # Opus 5. The images are small and the call is off the critical path, so there
74
- # is no reason to trade quality away here - this is the output a planter reads
75
- # and acts on, and bad advice about a real tree is worse than no advice.
76
- MODEL = "claude-opus-5"
77
-
78
- # Generous. The call is fire-and-forget from the caller's perspective and a slow
79
- # response costs nothing, whereas a truncated one wastes the whole request.
80
- MAX_TOKENS = 2000
81
-
82
- # Images are re-encoded to this before sending. The photos in storage are
83
- # already 800px/q75 (the client downscales before upload), so this is a ceiling
84
- # rather than a resize in the normal case, and it bounds the token cost of a
85
- # photo that arrived by some other route.
86
- MAX_EDGE_PX = 800
87
- JPEG_QUALITY = 75
88
-
89
-
90
- class PlantAssessment(BaseModel):
91
- """What we ask Claude to return, and what we store.
92
-
93
- Every field is chosen so that a reader can tell how much to trust it.
94
- `species_confidence` is SELF-REPORTED and labelled as such in the UI - it is
95
- not a measured accuracy and must never be presented as one.
96
- """
97
-
98
- species_common: str | None = Field(
99
- description="Common name of the species, or null if not identifiable."
100
- )
101
- species_scientific: str | None = Field(
102
- description="Binomial scientific name, or null if not identifiable."
103
- )
104
- species_confidence: float = Field(
105
- ge=0.0,
106
- le=1.0,
107
- description="Your own confidence in the species identification, 0 to 1.",
108
- )
109
- health: Literal["healthy", "stressed", "declining", "cannot_tell"] = Field(
110
- description="Overall condition of the plant as far as the photos show it."
111
- )
112
- observations: list[str] = Field(
113
- description="What is actually visible in the photographs. Concrete and "
114
- "specific: leaf colour, leaf loss, wilting, damage, the state of the "
115
- "soil. Do not speculate beyond what the image shows."
116
- )
117
- actions: list[str] = Field(
118
- description="What the planter should do, in plain language, achievable "
119
- "by one person with no equipment and no money."
120
- )
121
- limitations: str = Field(
122
- description="What these photographs could NOT tell you, and what would "
123
- "need to be checked in person."
124
- )
125
-
126
-
127
- # `limitations` being required is the design, not a formality. An assessment
128
- # that cannot say what it failed to see is indistinguishable from one that saw
129
- # everything, and a planter cannot calibrate how much to trust it.
130
- SYSTEM = """You are advising a smallholder tree planter in Ghana who has \
131
- photographed a young tree (roughly 1-3 years old) they are responsible for \
132
- keeping alive. They are paid when the tree survives, so your advice has real \
133
- consequences for them.
134
-
135
- You will receive one to three photographs of the same tree from the same visit: \
136
- a wide shot of the whole tree and its surroundings, a close-up of the trunk, \
137
- and sometimes a close-up of a leaf.
138
-
139
- Your job is to identify the species if you can, describe the tree's condition, \
140
- and tell the planter what to do about it.
141
-
142
- Rules:
143
-
144
- - Describe only what is visible. If the photographs do not show something, say \
145
- so in `limitations` rather than guessing at it.
146
- - Prefer species common in Ghanaian planting schemes where the image supports \
147
- it (Odum/Milicia, Wawa/Triplochiton, Ofram/Terminalia, Mahogany/Khaya, \
148
- Neem/Azadirachta, Ceiba, Mango/Mangifera, Cassia, Acacia, Teak/Tectona), but do \
149
- not force a match. Return null for species rather than a bad guess, and let \
150
- `species_confidence` reflect genuine uncertainty.
151
- - Recommend only actions a person can take with their hands, water, mulch and \
152
- local materials. No paid inputs, no laboratory tests, no equipment. Watering, \
153
- mulching, weeding around the base, removing competing growth, staking, \
154
- protecting from livestock, and clearing termite damage are the realistic \
155
- interventions.
156
- - If the tree looks healthy, say so plainly and give one or two things worth \
157
- keeping up. Do not invent problems.
158
- - Be brief. Two to four observations, two to four actions. This is read on a \
159
- phone, outdoors, by someone standing in front of the tree.
160
- - Never mention verification, scoring, confidence scores or payment. That is a \
161
- different part of this system and not your concern."""
162
-
163
-
164
- def _client():
165
- """Constructed per call, deliberately.
166
-
167
- The API key is read from the environment at call time rather than import
168
- time, so the module imports cleanly on a machine that has no key - which is
169
- every machine running the scoring path, since scoring must never acquire a
170
- dependency on this file.
171
- """
172
- import anthropic # imported here so a missing SDK cannot break scoring
173
-
174
- if not os.environ.get("ANTHROPIC_API_KEY"):
175
- raise RuntimeError(
176
- "ANTHROPIC_API_KEY is not set. Note that a Claude Pro subscription "
177
- "is NOT API access - the API bills separately at console.anthropic.com."
178
- )
179
- return anthropic.Anthropic()
180
-
181
-
182
- def _image_block(img: Image.Image, label: str) -> list[dict]:
183
- """One photo as a labelled pair of content blocks.
184
-
185
- The text label matters: without it the model cannot tell a trunk close-up
186
- from a leaf close-up, and will describe bark as foliage.
187
- """
188
- im = img.convert("RGB")
189
- im.thumbnail((MAX_EDGE_PX, MAX_EDGE_PX))
190
-
191
- buf = io.BytesIO()
192
- im.save(buf, format="JPEG", quality=JPEG_QUALITY)
193
- data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
194
-
195
- return [
196
- {"type": "text", "text": label},
197
- {
198
- "type": "image",
199
- "source": {"type": "base64", "media_type": "image/jpeg", "data": data},
200
- },
201
- ]
202
-
203
-
204
- def assess(
205
- *,
206
- wide: Image.Image | None = None,
207
- close: Image.Image | None = None,
208
- leaf: Image.Image | None = None,
209
- recorded_species: str | None = None,
210
- ) -> PlantAssessment | None:
211
- """Identify the species and assess the tree's health. None on any failure.
212
-
213
- EVERY PHOTO THAT EXISTS IS SENT, and none is required. That is what lets
214
- this run against the pilot check-ins captured before `leaf_photo` existed,
215
- and what lets it improve on its own as leaf photos start arriving - the same
216
- property the plant reference set has, where every verified check-in makes
217
- the next assessment slightly better.
218
-
219
- `recorded_species` is what the planter typed at registration. It is passed
220
- as CONTEXT TO DISAGREE WITH, never as an answer to confirm: the whole value
221
- of an independent identification is lost if we tell the model what to say.
222
- """
223
- blocks: list[dict] = []
224
- if wide is not None:
225
- blocks += _image_block(wide, "Wide shot: the whole tree and its surroundings.")
226
- if close is not None:
227
- blocks += _image_block(close, "Close-up of the trunk.")
228
- if leaf is not None:
229
- blocks += _image_block(leaf, "Close-up of a leaf.")
230
-
231
- if not blocks:
232
- log.warning("advisor.assess called with no photographs")
233
- return None
234
-
235
- prompt = "Identify this tree and assess its condition."
236
- if recorded_species:
237
- # Framed to invite contradiction. "The planter recorded X" is a claim to
238
- # test; "this is an X" would be an instruction to agree.
239
- prompt += (
240
- f"\n\nThe planter recorded this tree's species as '{recorded_species}' "
241
- "when they registered it. Treat that as an unverified claim, not as "
242
- "the answer. If the photographs show something else, say so."
243
- )
244
- blocks.append({"type": "text", "text": prompt})
245
-
246
- try:
247
- response = _client().messages.parse(
248
- model=MODEL,
249
- max_tokens=MAX_TOKENS,
250
- system=SYSTEM,
251
- messages=[{"role": "user", "content": blocks}],
252
- output_format=PlantAssessment,
253
- )
254
- except Exception: # noqa: BLE001 - advisory only, must never break a caller
255
- log.exception("advisory assessment failed")
256
- return None
257
-
258
- # A refusal is a successful HTTP call with no parsed output. Guard before
259
- # reading rather than raising out of a function documented never to raise.
260
- parsed = getattr(response, "parsed_output", None)
261
- if parsed is None:
262
- log.warning(
263
- "advisory assessment returned no parsed output (stop_reason=%s)",
264
- getattr(response, "stop_reason", None),
265
- )
266
- return None
267
-
268
- return parsed
269
-
270
-
271
- def split(assessment: PlantAssessment) -> tuple[dict, dict]:
272
- """One assessment -> the two columns it is stored in.
273
-
274
- Species and advice are separated in the database because they have different
275
- futures: species is a candidate verification signal (a species that changes
276
- between visits is evidence of a swapped tree), and advice never will be.
277
- Storing them together would make that separation a refactor later.
278
- """
279
- species = {
280
- "common": assessment.species_common,
281
- "scientific": assessment.species_scientific,
282
- # Named to make its nature unmissable at every layer. This is the
283
- # model's opinion of itself, not a measured accuracy, and calling the
284
- # field `confidence` next to a column that genuinely IS a measured
285
- # confidence would be actively misleading.
286
- "self_reported_confidence": assessment.species_confidence,
287
- "model": MODEL,
288
- }
289
- advice = {
290
- "health": assessment.health,
291
- "observations": assessment.observations,
292
- "actions": assessment.actions,
293
- "limitations": assessment.limitations,
294
- "model": MODEL,
295
- "advisory_only": True,
296
- }
297
- return species, advice
298
-
299
-
300
- # ---------------------------------------------------------------------------
301
- # When to spend a call
302
- # ---------------------------------------------------------------------------
303
- #
304
- # THE DESIGNED POLICY IS DECLINE-ONLY. The deterministic canopy signal in
305
- # scoring.score_growth already flags a tree that has lost more than half its
306
- # canopy since the last visit - that is the dying-tree detector, it costs
307
- # nothing, and it is measured. Calling an LLM only when that fires is what keeps
308
- # the per-tree cost defensible at scale:
309
- #
310
- # ~3 cents a call. One million trees checked four times a year is
311
- # ~$120k on every visit, ~$6-12k on decline only.
312
- #
313
- # WHY IT IS CURRENTLY TRUE FOR EVERY CHECK-IN ANYWAY. The pilot is 6 trees and
314
- # 9 check-ins, and most of those are first visits - where score_growth returns a
315
- # neutral 0.5 because there is no previous canopy to compare against. A
316
- # decline-only trigger would fire zero times on the data we actually have, and
317
- # an advisory layer with nothing to advise on is not a feature.
318
- #
319
- # So this is a policy constant with both branches live, rather than a hardcoded
320
- # call site. Both statements are true and both are defensible: we assess every
321
- # visit at pilot scale, and the design for scale is decline-only.
322
- ADVISE_ON_EVERY_CHECKIN = True
323
-
324
- # Below this growth score, the tree is declining enough to be worth advice.
325
- # Matches the 0.15 that score_growth assigns to >50% canopy loss.
326
- DECLINE_GROWTH_SCORE = 0.2
327
-
328
-
329
- def should_advise(checkin: dict) -> bool:
330
- """Is this check-in worth spending an API call on?"""
331
- if ADVISE_ON_EVERY_CHECKIN:
332
- return True
333
-
334
- growth = ((checkin.get("signals") or {}).get("scores") or {}).get("growth") or {}
335
- score = growth.get("score")
336
- # An unscored check-in has no decline evidence either way. Advising on it
337
- # would quietly restore "every check-in" through the back door.
338
- return score is not None and score <= DECLINE_GROWTH_SCORE
339
-
340
-
341
- def advise_checkin(checkin_id: str) -> dict | None:
342
- """Assess one check-in and store the result. None if nothing was written.
343
-
344
- Fetches its own photos from storage, exactly as scoring does, so the request
345
- body cannot influence what gets assessed.
346
- """
347
- from . import store # local: keeps the module importable without credentials
348
-
349
- row = store.get_checkin(checkin_id)
350
- if row is None:
351
- raise LookupError(f"No check-in {checkin_id}")
352
-
353
- if not should_advise(row):
354
- log.info("skipping advice for %s: no decline detected", checkin_id)
355
- return None
356
-
357
- tree = store.get_tree(row["tree_id"])
358
-
359
- def _photo(path: str | None) -> Image.Image | None:
360
- """A missing or unreadable photo costs us that photo, never the call."""
361
- if not path:
362
- return None
363
- try:
364
- return store.download_image(path)
365
- except Exception: # noqa: BLE001
366
- log.warning("could not download %s", path, exc_info=True)
367
- return None
368
-
369
- assessment = assess(
370
- wide=_photo(row.get("wide_photo")),
371
- close=_photo(row.get("close_photo")),
372
- leaf=_photo(row.get("leaf_photo")),
373
- recorded_species=(tree or {}).get("species"),
374
- )
375
- if assessment is None:
376
- return None
377
-
378
- species, advice = split(assessment)
379
- store.write_advice(checkin_id, species, advice)
380
- log.info(
381
- "advised %s -> %s (species: %s)",
382
- checkin_id,
383
- assessment.health,
384
- assessment.species_common,
385
- )
386
- return {"species_guess": species, "advice": advice}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Species identification and care advice for the planter.
2
+
3
+ THE ONE THING TO UNDERSTAND ABOUT THIS FILE
4
+
5
+ Nothing here is a verification signal, and nothing here may become one.
6
+
7
+ The verification engine answers "is this the same tree, alive, in the right
8
+ place, not a duplicate" - and it answers it with detectors whose error rate we
9
+ have measured and published. This file answers a different question that nobody
10
+ has been answering at all: "what is wrong with this tree and what should the
11
+ planter do about it?"
12
+
13
+ Those two questions deserve different standards of evidence, so they are kept
14
+ apart at every level: a separate module THE SCORING PATH NEVER IMPORTS, a
15
+ separate endpoint, separate database columns, and a separate place in the UI
16
+ that names the model and says the word "advice".
17
+
18
+ The import direction is the load-bearing part. `pipeline.py` does not know this
19
+ file exists, so there is no code path by which a slow, failed or hallucinated
20
+ advisory call can affect a verdict. Check that property still holds before
21
+ adding any import to this module's callers:
22
+
23
+ grep -rn "advisor" ml/greenproof_ml/pipeline.py ml/greenproof_ml/scoring.py
24
+
25
+ should print nothing, permanently.
26
+
27
+ WHY AN LLM HERE, WHEN WE REFUSED ONE EVERYWHERE ELSE
28
+
29
+ The obvious alternative is a leaf-disease classifier trained on PlantVillage.
30
+ We are not doing that, for three reasons and the first is disqualifying:
31
+
32
+ 1. PlantVillage is single leaves on uniform lab backgrounds. Published
33
+ cross-domain evaluations collapse from ~99% to roughly 30-50% on real
34
+ field photographs. We have already made exactly this mistake once and
35
+ turned it into our best slide: the plant check scored a perfect AUC of
36
+ 1.000 and then flagged five of nine real check-ins as "not a plant",
37
+ because it had learned photo STYLE, not subject. Shipping a PlantVillage
38
+ classifier would be repeating a mistake we have documented.
39
+
40
+ 2. It covers fourteen crops - tomato, potato, corn, grape. None of them are
41
+ Odum, Wawa, Ofram, Ceiba, or anything else in a Ghanaian planting scheme.
42
+
43
+ 3. We have no ground truth. No agronomist has labelled our trees. This
44
+ project's entire claim is that we publish our own measured error rate, and
45
+ a classifier whose error rate we cannot measure would be the one component
46
+ contradicting that claim.
47
+
48
+ An LLM's output is hedged natural language that a person reads and judges, not
49
+ a number that authorises a payment. The bar it has to clear is therefore much
50
+ lower, and it is honest about clearing a lower bar - which is why `limitations`
51
+ below is a required field rather than an optional one.
52
+
53
+ FAILS SOFT, ALWAYS. No key, no network, a rate limit, a refusal, a malformed
54
+ response: every one of them logs and returns None. The caller writes nothing and
55
+ the check-in is untouched. This service being unreachable already costs us
56
+ nothing (scoring is asynchronous and replayable); the advisory layer inherits
57
+ that property rather than weakening it.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import base64
63
+ import io
64
+ import logging
65
+ import os
66
+ import time
67
+ from typing import Literal
68
+
69
+ from PIL import Image
70
+ from pydantic import BaseModel, Field
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+
75
+ class AdvisorError(RuntimeError):
76
+ """The provider was asked and could not answer.
77
+
78
+ Distinct from "there was nothing to do". Raised only when strict=True, so
79
+ batch tools can report a dead provider loudly while the HTTP endpoint keeps
80
+ failing soft.
81
+ """
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Which model writes the advice
86
+ # ---------------------------------------------------------------------------
87
+ #
88
+ # TWO PROVIDERS BEHIND ONE FUNCTION, the same shape as photos (one upload
89
+ # function, one URL column) and payments (a mock adapter behind an interface).
90
+ # The advisory layer is the only part of this system that talks to an outside
91
+ # API, so it is the part most likely to be swapped under us - free tiers have
92
+ # already closed on this project three times mid-build.
93
+ #
94
+ # GEMINI free tier, and the default when a key is present
95
+ # ANTHROPIC paid, used when only that key is set
96
+ #
97
+ # Auto-detected from whichever key exists, overridable with ADVISOR_PROVIDER.
98
+ # Neither key set is a supported state: advice is disabled and every other part
99
+ # of the system carries on unaffected.
100
+ # VERIFY A MODEL ID BY CALLING IT, NOT BY LISTING IT. `models.list()` returned
101
+ # gemini-2.5-flash as available while generate_content refused it with "no longer
102
+ # available to new users" - the listing describes the catalogue, not what this
103
+ # key may actually invoke. Overridable with GEMINI_MODEL, so a future retirement
104
+ # is an environment variable rather than a deploy.
105
+ GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.6-flash")
106
+
107
+ # Free-tier quota is PER MODEL, and that is the whole reason this list exists.
108
+ # A full pilot pass exhausted gemini-3.6-flash's daily allowance after 20 of 37
109
+ # check-ins, while the other two still answered immediately - so a 429 on one
110
+ # model is not "we are out of quota", it is "we are out of quota HERE".
111
+ #
112
+ # Tried in order on a rate-limit only. Never on a 404, a refusal or a bad
113
+ # request: those mean the call was wrong, and silently re-issuing a wrong call
114
+ # against three models just produces the same failure three times.
115
+ GEMINI_FALLBACKS = [
116
+ m.strip()
117
+ for m in os.environ.get(
118
+ "GEMINI_FALLBACKS", "gemini-3-flash-preview,gemini-3.1-flash-lite"
119
+ ).split(",")
120
+ if m.strip()
121
+ ]
122
+ ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-5")
123
+
124
+ # Generous, and it has to be - see below. The call is fire-and-forget from the
125
+ # caller's perspective and a slow response costs nothing, whereas a truncated one
126
+ # wastes the whole request.
127
+ #
128
+ # THINKING TOKENS COUNT AGAINST THIS, which is not obvious and cost us a whole
129
+ # debugging round. At 2000 a real call came back:
130
+ #
131
+ # finish_reason MAX_TOKENS
132
+ # thoughts_token_count 1916
133
+ # candidates_token_count 69 <- the actual answer
134
+ #
135
+ # The model spent the budget reasoning and had 69 tokens left for the JSON, so
136
+ # it truncated mid-string and parsed to None. The failure looks like "the model
137
+ # returned nothing", which points nowhere near the real cause.
138
+ #
139
+ # The answer itself is only a few hundred tokens; the headroom is for thinking.
140
+ MAX_TOKENS = 8000
141
+
142
+
143
+ def provider() -> str | None:
144
+ """Which provider to use, or None if advice is unavailable.
145
+
146
+ An explicit ADVISOR_PROVIDER wins, so a demo can be pinned to one provider
147
+ regardless of which keys happen to be present in the environment.
148
+ """
149
+ explicit = os.environ.get("ADVISOR_PROVIDER", "").strip().lower()
150
+ if explicit in ("gemini", "anthropic"):
151
+ return explicit
152
+ if os.environ.get("GEMINI_API_KEY"):
153
+ return "gemini"
154
+ if os.environ.get("ANTHROPIC_API_KEY"):
155
+ return "anthropic"
156
+ return None
157
+
158
+
159
+ def model_name() -> str | None:
160
+ """The model that answered, falling back to the one we would ask.
161
+
162
+ Prefers the ACTUAL model once a call has been made, so a row records what
163
+ wrote it rather than what we hoped would write it.
164
+ """
165
+ p = provider()
166
+ if p is None:
167
+ return None
168
+ if p in _used_model:
169
+ return _used_model[p]
170
+ return {"gemini": GEMINI_MODEL, "anthropic": ANTHROPIC_MODEL}[p]
171
+
172
+ # Images are re-encoded to this before sending. The photos in storage are
173
+ # already 800px/q75 (the client downscales before upload), so this is a ceiling
174
+ # rather than a resize in the normal case, and it bounds the token cost of a
175
+ # photo that arrived by some other route.
176
+ MAX_EDGE_PX = 800
177
+ JPEG_QUALITY = 75
178
+
179
+
180
+ class PlantAssessment(BaseModel):
181
+ """What we ask the model to return, and what we store.
182
+
183
+ One schema for both providers, enforced by schema-constrained decoding on
184
+ each - so the stored shape is identical whichever wrote it, and switching
185
+ provider cannot quietly change the data.
186
+
187
+ Every field is chosen so that a reader can tell how much to trust it.
188
+ `species_confidence` is SELF-REPORTED and labelled as such in the UI - it is
189
+ not a measured accuracy and must never be presented as one.
190
+ """
191
+
192
+ species_common: str | None = Field(
193
+ description="Common name of the species, or null if not identifiable."
194
+ )
195
+ species_scientific: str | None = Field(
196
+ description="Binomial scientific name, or null if not identifiable."
197
+ )
198
+ species_confidence: float = Field(
199
+ ge=0.0,
200
+ le=1.0,
201
+ description="Your own confidence in the species identification, 0 to 1.",
202
+ )
203
+ health: Literal["healthy", "stressed", "declining", "cannot_tell"] = Field(
204
+ description="Overall condition of the plant as far as the photos show it."
205
+ )
206
+ observations: list[str] = Field(
207
+ description="What is actually visible in the photographs. Concrete and "
208
+ "specific: leaf colour, leaf loss, wilting, damage, the state of the "
209
+ "soil. Do not speculate beyond what the image shows."
210
+ )
211
+ actions: list[str] = Field(
212
+ description="What the planter should do, in plain language, achievable "
213
+ "by one person with no equipment and no money."
214
+ )
215
+ limitations: str = Field(
216
+ description="What these photographs could NOT tell you, and what would "
217
+ "need to be checked in person."
218
+ )
219
+
220
+
221
+ # `limitations` being required is the design, not a formality. An assessment
222
+ # that cannot say what it failed to see is indistinguishable from one that saw
223
+ # everything, and a planter cannot calibrate how much to trust it.
224
+ SYSTEM = """You are advising a smallholder tree planter in Ghana who has \
225
+ photographed a young tree (roughly 1-3 years old) they are responsible for \
226
+ keeping alive. They are paid when the tree survives, so your advice has real \
227
+ consequences for them.
228
+
229
+ You will receive one to three photographs of the same tree from the same visit: \
230
+ a wide shot of the whole tree and its surroundings, a close-up of the trunk, \
231
+ and sometimes a close-up of a leaf.
232
+
233
+ Your job is to identify the species if you can, describe the tree's condition, \
234
+ and tell the planter what to do about it.
235
+
236
+ Rules:
237
+
238
+ - Describe only what is visible. If the photographs do not show something, say \
239
+ so in `limitations` rather than guessing at it.
240
+ - Prefer species common in Ghanaian planting schemes where the image supports \
241
+ it (Odum/Milicia, Wawa/Triplochiton, Ofram/Terminalia, Mahogany/Khaya, \
242
+ Neem/Azadirachta, Ceiba, Mango/Mangifera, Cassia, Acacia, Teak/Tectona), but do \
243
+ not force a match. Return null for species rather than a bad guess, and let \
244
+ `species_confidence` reflect genuine uncertainty.
245
+ - Recommend only actions a person can take with their hands, water, mulch and \
246
+ local materials. No paid inputs, no laboratory tests, no equipment. Watering, \
247
+ mulching, weeding around the base, removing competing growth, staking, \
248
+ protecting from livestock, and clearing termite damage are the realistic \
249
+ interventions.
250
+ - If the tree looks healthy, say so plainly and give one or two things worth \
251
+ keeping up. Do not invent problems.
252
+ - Be brief. Two to four observations, two to four actions. This is read on a \
253
+ phone, outdoors, by someone standing in front of the tree.
254
+ - Never mention verification, scoring, confidence scores or payment. That is a \
255
+ different part of this system and not your concern."""
256
+
257
+
258
+ # Which model actually answered, as opposed to which one we asked first. Read by
259
+ # split() so the row records the truth rather than the intention - a fallback
260
+ # that is not written down is a result nobody can reproduce.
261
+ _used_model: dict[str, str] = {}
262
+
263
+
264
+ def _is_rate_limit(e: Exception) -> bool:
265
+ """A quota error, as opposed to a wrong request.
266
+
267
+ Matched on the response rather than the exception class: the SDK raises the
268
+ same ClientError for 404 and 429, and retrying a 404 across three models
269
+ just produces the same failure three times.
270
+ """
271
+ code = getattr(e, "code", None) or getattr(e, "status_code", None)
272
+ if code == 429:
273
+ return True
274
+ s = str(e)
275
+ return "429" in s or "RESOURCE_EXHAUSTED" in s
276
+
277
+
278
+ def _jpeg(img: Image.Image) -> bytes:
279
+ """One photo, normalised to what we actually send.
280
+
281
+ Storage photos are already 800px/q75 (the client downscales before upload),
282
+ so this is a ceiling rather than a resize in the normal case. It bounds the
283
+ cost of a photo that arrived by some other route, and it makes the bytes
284
+ identical whichever provider is called - so switching provider cannot
285
+ silently change what the model saw.
286
+ """
287
+ im = img.convert("RGB")
288
+ im.thumbnail((MAX_EDGE_PX, MAX_EDGE_PX))
289
+ buf = io.BytesIO()
290
+ im.save(buf, format="JPEG", quality=JPEG_QUALITY)
291
+ return buf.getvalue()
292
+
293
+
294
+ def _labelled(wide, close, leaf) -> list[tuple[str, bytes]]:
295
+ """Photos with the labels the model needs to tell them apart.
296
+
297
+ The labels matter: without them the model cannot distinguish a trunk
298
+ close-up from a leaf close-up, and will confidently describe bark as
299
+ foliage.
300
+ """
301
+ out = []
302
+ if wide is not None:
303
+ out.append(("Wide shot: the whole tree and its surroundings.", _jpeg(wide)))
304
+ if close is not None:
305
+ out.append(("Close-up of the trunk.", _jpeg(close)))
306
+ if leaf is not None:
307
+ out.append(("Close-up of a leaf.", _jpeg(leaf)))
308
+ return out
309
+
310
+
311
+ def _assess_gemini(photos, prompt: str) -> PlantAssessment | None:
312
+ """Google's free tier. SDK imported here so a missing package cannot break
313
+ scoring, which must never depend on this module."""
314
+ from google import genai
315
+ from google.genai import types
316
+
317
+ # DO NOT LET THE SDK RETRY THE 429 FOR US.
318
+ #
319
+ # By default it retries rate limits internally with backoff, so a call
320
+ # against an exhausted model sits there for minutes before raising - a full
321
+ # pass appeared to hang rather than fail. We have a better answer than
322
+ # waiting: a different model with its own quota. Surface the 429 on the
323
+ # first response so the fallback below can act on it immediately.
324
+ #
325
+ # 5xx and 503 are still worth one retry - those are transient in a way a
326
+ # daily quota is not.
327
+ client = genai.Client(
328
+ api_key=os.environ["GEMINI_API_KEY"],
329
+ http_options=types.HttpOptions(
330
+ timeout=90_000, # ms
331
+ retry_options=types.HttpRetryOptions(
332
+ attempts=2, http_status_codes=[500, 502, 503, 504]
333
+ ),
334
+ ),
335
+ )
336
+
337
+ parts = []
338
+ for label, data in photos:
339
+ parts.append(types.Part.from_text(text=label))
340
+ parts.append(types.Part.from_bytes(data=data, mime_type="image/jpeg"))
341
+ parts.append(types.Part.from_text(text=prompt))
342
+
343
+ config = types.GenerateContentConfig(
344
+ system_instruction=SYSTEM,
345
+ max_output_tokens=MAX_TOKENS,
346
+ # Schema-constrained decoding, so the answer is valid JSON in the shape
347
+ # we asked for rather than prose we have to parse.
348
+ response_mime_type="application/json",
349
+ response_schema=PlantAssessment,
350
+ )
351
+
352
+ last: Exception | None = None
353
+ for i, model in enumerate([GEMINI_MODEL, *GEMINI_FALLBACKS]):
354
+ try:
355
+ resp = client.models.generate_content(
356
+ model=model,
357
+ contents=[types.Content(role="user", parts=parts)],
358
+ config=config,
359
+ )
360
+ if i:
361
+ # Record it: a run that quietly changed model halfway is a run
362
+ # whose output nobody can explain later.
363
+ log.warning("gemini fell back to %s after quota on %s", model, GEMINI_MODEL)
364
+ _used_model["gemini"] = model
365
+ else:
366
+ _used_model["gemini"] = model
367
+ return resp.parsed
368
+ except Exception as e: # noqa: BLE001
369
+ last = e
370
+ if not _is_rate_limit(e):
371
+ raise # a 404 or a bad request will fail identically on every model
372
+ log.info("quota exhausted on %s, trying next", model)
373
+
374
+ raise last if last else AdvisorError("gemini: no model available")
375
+
376
+
377
+ def _assess_anthropic(photos, prompt: str) -> PlantAssessment | None:
378
+ from anthropic import Anthropic
379
+
380
+ blocks: list[dict] = []
381
+ for label, data in photos:
382
+ blocks.append({"type": "text", "text": label})
383
+ blocks.append(
384
+ {
385
+ "type": "image",
386
+ "source": {
387
+ "type": "base64",
388
+ "media_type": "image/jpeg",
389
+ "data": base64.standard_b64encode(data).decode("utf-8"),
390
+ },
391
+ }
392
+ )
393
+ blocks.append({"type": "text", "text": prompt})
394
+
395
+ resp = Anthropic().messages.parse(
396
+ model=ANTHROPIC_MODEL,
397
+ max_tokens=MAX_TOKENS,
398
+ system=SYSTEM,
399
+ messages=[{"role": "user", "content": blocks}],
400
+ output_format=PlantAssessment,
401
+ )
402
+ return getattr(resp, "parsed_output", None)
403
+
404
+
405
+ def assess(
406
+ *,
407
+ wide: Image.Image | None = None,
408
+ close: Image.Image | None = None,
409
+ leaf: Image.Image | None = None,
410
+ recorded_species: str | None = None,
411
+ strict: bool = False,
412
+ ) -> PlantAssessment | None:
413
+ """Identify the species and assess the tree's health. None on any failure.
414
+
415
+ EVERY PHOTO THAT EXISTS IS SENT, and none is required. That is what lets
416
+ this run against the pilot check-ins captured before `leaf_photo` existed,
417
+ and what lets it improve on its own as leaf photos start arriving - the same
418
+ property the plant reference set has, where every verified check-in makes
419
+ the next assessment slightly better.
420
+
421
+ `recorded_species` is what the planter typed at registration. It is passed
422
+ as CONTEXT TO DISAGREE WITH, never as an answer to confirm: the whole value
423
+ of an independent identification is lost if we tell the model what to say.
424
+ """
425
+ photos = _labelled(wide, close, leaf)
426
+ if not photos:
427
+ log.warning("advisor.assess called with no photographs")
428
+ return None
429
+
430
+ which = provider()
431
+ if which is None:
432
+ # Not an error. No key configured means advice is switched off, and
433
+ # everything else in the system is unaffected.
434
+ log.info("no advisory provider configured (set GEMINI_API_KEY)")
435
+ return None
436
+
437
+ prompt = "Identify this tree and assess its condition."
438
+ if recorded_species:
439
+ # Framed to invite contradiction. "The planter recorded X" is a claim to
440
+ # test; "this is an X" would be an instruction to agree.
441
+ prompt += (
442
+ f"\n\nThe planter recorded this tree's species as '{recorded_species}' "
443
+ "when they registered it. Treat that as an unverified claim, not as "
444
+ "the answer. If the photographs show something else, say so."
445
+ )
446
+
447
+ try:
448
+ parsed = (
449
+ _assess_gemini(photos, prompt)
450
+ if which == "gemini"
451
+ else _assess_anthropic(photos, prompt)
452
+ )
453
+ except Exception as e: # noqa: BLE001 - advisory only, must never break a caller
454
+ log.exception("advisory assessment failed (%s)", which)
455
+ # A DEAD PROVIDER MUST NOT LOOK LIKE A QUIET SKIP.
456
+ #
457
+ # This returned None on failure, and the pilot tool counted None as
458
+ # "skipped - no decline detected, or the model declined". A 404 on a
459
+ # retired model id therefore reported as a clean run over every
460
+ # check-in, which is precisely the false pass this project has been
461
+ # caught by three times before.
462
+ #
463
+ # The endpoint still fails soft (strict=False) because a broken advisory
464
+ # call must never take verification down. Batch tools pass strict=True,
465
+ # because a tool that cannot tell "nothing to do" from "nothing worked"
466
+ # is worse than no tool.
467
+ if strict:
468
+ raise AdvisorError(f"{which}: {e}") from e
469
+ return None
470
+
471
+ # A refusal or a safety block is a successful call with nothing parsed.
472
+ # Guard before reading rather than raising out of a function documented
473
+ # never to raise.
474
+ if parsed is None:
475
+ log.warning("advisory assessment returned no parsed output (%s)", which)
476
+ if strict:
477
+ raise AdvisorError(
478
+ f"{which}: returned no parsed output - most likely the response "
479
+ "was truncated, check finish_reason and MAX_TOKENS"
480
+ )
481
+ return None
482
+
483
+ return parsed
484
+
485
+
486
+ def split(assessment: PlantAssessment) -> tuple[dict, dict]:
487
+ """One assessment -> the two columns it is stored in.
488
+
489
+ Species and advice are separated in the database because they have different
490
+ futures: species is a candidate verification signal (a species that changes
491
+ between visits is evidence of a swapped tree), and advice never will be.
492
+ Storing them together would make that separation a refactor later.
493
+ """
494
+ species = {
495
+ "common": assessment.species_common,
496
+ "scientific": assessment.species_scientific,
497
+ # Named to make its nature unmissable at every layer. This is the
498
+ # model's opinion of itself, not a measured accuracy, and calling the
499
+ # field `confidence` next to a column that genuinely IS a measured
500
+ # confidence would be actively misleading.
501
+ "self_reported_confidence": assessment.species_confidence,
502
+ "model": model_name(),
503
+ }
504
+ advice = {
505
+ "health": assessment.health,
506
+ "observations": assessment.observations,
507
+ "actions": assessment.actions,
508
+ "limitations": assessment.limitations,
509
+ "model": model_name(),
510
+ "advisory_only": True,
511
+ }
512
+ return species, advice
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # When to spend a call
517
+ # ---------------------------------------------------------------------------
518
+ #
519
+ # THE DESIGNED POLICY IS DECLINE-ONLY. The deterministic canopy signal in
520
+ # scoring.score_growth already flags a tree that has lost more than half its
521
+ # canopy since the last visit - that is the dying-tree detector, it costs
522
+ # nothing, and it is measured. Calling an LLM only when that fires is what keeps
523
+ # the per-tree cost defensible at scale:
524
+ #
525
+ # ~3 cents a call. One million trees checked four times a year is
526
+ # ~$120k on every visit, ~$6-12k on decline only.
527
+ #
528
+ # WHY IT IS CURRENTLY TRUE FOR EVERY CHECK-IN ANYWAY. The pilot is 6 trees and
529
+ # 9 check-ins, and most of those are first visits - where score_growth returns a
530
+ # neutral 0.5 because there is no previous canopy to compare against. A
531
+ # decline-only trigger would fire zero times on the data we actually have, and
532
+ # an advisory layer with nothing to advise on is not a feature.
533
+ #
534
+ # So this is a policy constant with both branches live, rather than a hardcoded
535
+ # call site. Both statements are true and both are defensible: we assess every
536
+ # visit at pilot scale, and the design for scale is decline-only.
537
+ ADVISE_ON_EVERY_CHECKIN = True
538
+
539
+ # Below this growth score, the tree is declining enough to be worth advice.
540
+ # Matches the 0.15 that score_growth assigns to >50% canopy loss.
541
+ DECLINE_GROWTH_SCORE = 0.2
542
+
543
+
544
+ def should_advise(checkin: dict) -> bool:
545
+ """Is this check-in worth spending an API call on?"""
546
+ if ADVISE_ON_EVERY_CHECKIN:
547
+ return True
548
+
549
+ growth = ((checkin.get("signals") or {}).get("scores") or {}).get("growth") or {}
550
+ score = growth.get("score")
551
+ # An unscored check-in has no decline evidence either way. Advising on it
552
+ # would quietly restore "every check-in" through the back door.
553
+ return score is not None and score <= DECLINE_GROWTH_SCORE
554
+
555
+
556
+ def advise_checkin(checkin_id: str, strict: bool = False) -> dict | None:
557
+ """Assess one check-in and store the result. None if nothing was written.
558
+
559
+ Fetches its own photos from storage, exactly as scoring does, so the request
560
+ body cannot influence what gets assessed.
561
+ """
562
+ from . import store # local: keeps the module importable without credentials
563
+
564
+ row = store.get_checkin(checkin_id)
565
+ if row is None:
566
+ raise LookupError(f"No check-in {checkin_id}")
567
+
568
+ if not should_advise(row):
569
+ log.info("skipping advice for %s: no decline detected", checkin_id)
570
+ return None
571
+
572
+ tree = store.get_tree(row["tree_id"])
573
+
574
+ def _photo(path: str | None) -> Image.Image | None:
575
+ """A missing or unreadable photo costs us that photo, never the call."""
576
+ if not path:
577
+ return None
578
+ try:
579
+ return store.download_image(path)
580
+ except Exception: # noqa: BLE001
581
+ log.warning("could not download %s", path, exc_info=True)
582
+ return None
583
+
584
+ photos = dict(
585
+ wide=_photo(row.get("wide_photo")),
586
+ close=_photo(row.get("close_photo")),
587
+ leaf=_photo(row.get("leaf_photo")),
588
+ recorded_species=(tree or {}).get("species"),
589
+ )
590
+
591
+ # ONE RETRY, because a lost assessment is silent.
592
+ #
593
+ # assess() already walks the model fallback chain on a quota error, but a
594
+ # 504 DEADLINE_EXCEEDED is different: the model simply took too long on that
595
+ # attempt, and the same model usually answers on the next one. Without this
596
+ # the visit keeps its verdict and quietly carries no advice, which is exactly
597
+ # the failure the planter reported - the feature working and being invisible.
598
+ #
599
+ # Two attempts, not more: this runs in a background thread per check-in, and
600
+ # a model that fails twice is not going to succeed on the fifth try.
601
+ assessment = None
602
+ for attempt in (1, 2):
603
+ assessment = assess(**photos, strict=strict and attempt == 2)
604
+ if assessment is not None:
605
+ break
606
+ if attempt == 1:
607
+ log.warning("advice attempt 1 failed for %s, retrying once", checkin_id)
608
+ time.sleep(2)
609
+
610
+ if assessment is None:
611
+ return None
612
+
613
+ species, advice = split(assessment)
614
+ store.write_advice(checkin_id, species, advice)
615
+ log.info(
616
+ "advised %s -> %s (species: %s)",
617
+ checkin_id,
618
+ assessment.health,
619
+ assessment.species_common,
620
+ )
621
+ return {"species_guess": species, "advice": advice}
requirements.txt CHANGED
@@ -1,26 +1,32 @@
1
- # Pinned loosely — Hugging Face Spaces rebuilds on push and a surprise major
2
- # version on pitch week is not a risk worth taking for a few KB of convenience.
3
-
4
- fastapi>=0.115,<1
5
- uvicorn[standard]>=0.32,<1
6
- pydantic>=2.9,<3
7
-
8
- # onnxruntime, NOT torch. Torch pulls ~2 GB and would make cold starts on the
9
- # free Spaces tier unusable. Torch stays a dev-only dependency for the spike.
10
- onnxruntime>=1.19,<2
11
- huggingface_hub>=0.26,<1
12
-
13
- numpy>=1.26,<3
14
- pillow>=10.4,<12
15
-
16
- # contrib, not plain opencv — cv2.aruco lives in contrib and the ArUco marker is
17
- # how a photo becomes a measurement in millimetres.
18
- opencv-contrib-python-headless>=4.10,<5
19
-
20
- supabase>=2.9,<3
21
- httpx>=0.27,<1
22
-
23
- # Advisory layer only: species identification and care advice for the planter.
24
- # NOT on the scoring path - pipeline.py never imports the advisor, so a slow or
25
- # failed API call cannot affect a verdict.
26
- anthropic>=0.40
 
 
 
 
 
 
 
1
+ # Pinned loosely — Hugging Face Spaces rebuilds on push and a surprise major
2
+ # version on pitch week is not a risk worth taking for a few KB of convenience.
3
+
4
+ fastapi>=0.115,<1
5
+ uvicorn[standard]>=0.32,<1
6
+ pydantic>=2.9,<3
7
+
8
+ # onnxruntime, NOT torch. Torch pulls ~2 GB and would make cold starts on the
9
+ # free Spaces tier unusable. Torch stays a dev-only dependency for the spike.
10
+ onnxruntime>=1.19,<2
11
+ huggingface_hub>=0.26,<1
12
+
13
+ numpy>=1.26,<3
14
+ pillow>=10.4,<12
15
+
16
+ # contrib, not plain opencv — cv2.aruco lives in contrib and the ArUco marker is
17
+ # how a photo becomes a measurement in millimetres.
18
+ opencv-contrib-python-headless>=4.10,<5
19
+
20
+ supabase>=2.9,<3
21
+ httpx>=0.27,<1
22
+
23
+ # Advisory layer only: species identification and care advice for the planter.
24
+ # NOT on the scoring path - pipeline.py never imports the advisor, so a slow or
25
+ # failed API call cannot affect a verdict.
26
+ #
27
+ # TWO PROVIDERS, picked at runtime from whichever key is set. Gemini is the
28
+ # default because its free tier costs nothing; Anthropic stays available because
29
+ # free tiers have closed under this project three times already and a second
30
+ # provider is the cheapest insurance against a fourth.
31
+ google-genai>=1.0
32
+ anthropic>=0.40