Spaces:
Running
Running
Generate advice automatically when a check-in is scored
Browse files- README.md +12 -5
- app.py +48 -0
- greenproof_ml/advisor.py +621 -386
- 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 |
-
| `
|
|
|
|
|
|
|
| 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 |
-
**
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
from
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
#
|
| 85 |
-
#
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
#
|
| 128 |
-
#
|
| 129 |
-
#
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
#
|
| 318 |
-
#
|
| 319 |
-
#
|
| 320 |
-
#
|
| 321 |
-
#
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
#
|
| 325 |
-
#
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
return
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
)
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|