Spaces:
Running
Running
File size: 26,652 Bytes
11dfae8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 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 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 | """Species identification and care advice for the planter.
THE ONE THING TO UNDERSTAND ABOUT THIS FILE
Nothing here is a verification signal, and nothing here may become one.
The verification engine answers "is this the same tree, alive, in the right
place, not a duplicate" - and it answers it with detectors whose error rate we
have measured and published. This file answers a different question that nobody
has been answering at all: "what is wrong with this tree and what should the
planter do about it?"
Those two questions deserve different standards of evidence, so they are kept
apart at every level: a separate module THE SCORING PATH NEVER IMPORTS, a
separate endpoint, separate database columns, and a separate place in the UI
that names the model and says the word "advice".
The import direction is the load-bearing part. `pipeline.py` does not know this
file exists, so there is no code path by which a slow, failed or hallucinated
advisory call can affect a verdict. Check that property still holds before
adding any import to this module's callers:
grep -rn "advisor" ml/greenproof_ml/pipeline.py ml/greenproof_ml/scoring.py
should print nothing, permanently.
WHY AN LLM HERE, WHEN WE REFUSED ONE EVERYWHERE ELSE
The obvious alternative is a leaf-disease classifier trained on PlantVillage.
We are not doing that, for three reasons and the first is disqualifying:
1. PlantVillage is single leaves on uniform lab backgrounds. Published
cross-domain evaluations collapse from ~99% to roughly 30-50% on real
field photographs. We have already made exactly this mistake once and
turned it into our best slide: the plant check scored a perfect AUC of
1.000 and then flagged five of nine real check-ins as "not a plant",
because it had learned photo STYLE, not subject. Shipping a PlantVillage
classifier would be repeating a mistake we have documented.
2. It covers fourteen crops - tomato, potato, corn, grape. None of them are
Odum, Wawa, Ofram, Ceiba, or anything else in a Ghanaian planting scheme.
3. We have no ground truth. No agronomist has labelled our trees. This
project's entire claim is that we publish our own measured error rate, and
a classifier whose error rate we cannot measure would be the one component
contradicting that claim.
An LLM's output is hedged natural language that a person reads and judges, not
a number that authorises a payment. The bar it has to clear is therefore much
lower, and it is honest about clearing a lower bar - which is why `limitations`
below is a required field rather than an optional one.
FAILS SOFT, ALWAYS. No key, no network, a rate limit, a refusal, a malformed
response: every one of them logs and returns None. The caller writes nothing and
the check-in is untouched. This service being unreachable already costs us
nothing (scoring is asynchronous and replayable); the advisory layer inherits
that property rather than weakening it.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import time
from typing import Literal
from PIL import Image
from pydantic import BaseModel, Field
log = logging.getLogger(__name__)
class AdvisorError(RuntimeError):
"""The provider was asked and could not answer.
Distinct from "there was nothing to do". Raised only when strict=True, so
batch tools can report a dead provider loudly while the HTTP endpoint keeps
failing soft.
"""
# ---------------------------------------------------------------------------
# Which model writes the advice
# ---------------------------------------------------------------------------
#
# TWO PROVIDERS BEHIND ONE FUNCTION, the same shape as photos (one upload
# function, one URL column) and payments (a mock adapter behind an interface).
# The advisory layer is the only part of this system that talks to an outside
# API, so it is the part most likely to be swapped under us - free tiers have
# already closed on this project three times mid-build.
#
# GEMINI free tier, and the default when a key is present
# ANTHROPIC paid, used when only that key is set
#
# Auto-detected from whichever key exists, overridable with ADVISOR_PROVIDER.
# Neither key set is a supported state: advice is disabled and every other part
# of the system carries on unaffected.
# VERIFY A MODEL ID BY CALLING IT, NOT BY LISTING IT. `models.list()` returned
# gemini-2.5-flash as available while generate_content refused it with "no longer
# available to new users" - the listing describes the catalogue, not what this
# key may actually invoke. Overridable with GEMINI_MODEL, so a future retirement
# is an environment variable rather than a deploy.
GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.6-flash")
# Free-tier quota is PER MODEL, and that is the whole reason this list exists.
# A full pilot pass exhausted gemini-3.6-flash's daily allowance after 20 of 37
# check-ins, while the other two still answered immediately - so a 429 on one
# model is not "we are out of quota", it is "we are out of quota HERE".
#
# Tried in order on a rate-limit only. Never on a 404, a refusal or a bad
# request: those mean the call was wrong, and silently re-issuing a wrong call
# against three models just produces the same failure three times.
GEMINI_FALLBACKS = [
m.strip()
for m in os.environ.get(
"GEMINI_FALLBACKS", "gemini-3-flash-preview,gemini-3.1-flash-lite"
).split(",")
if m.strip()
]
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-5")
# Generous, and it has to be - see below. The call is fire-and-forget from the
# caller's perspective and a slow response costs nothing, whereas a truncated one
# wastes the whole request.
#
# THINKING TOKENS COUNT AGAINST THIS, which is not obvious and cost us a whole
# debugging round. At 2000 a real call came back:
#
# finish_reason MAX_TOKENS
# thoughts_token_count 1916
# candidates_token_count 69 <- the actual answer
#
# The model spent the budget reasoning and had 69 tokens left for the JSON, so
# it truncated mid-string and parsed to None. The failure looks like "the model
# returned nothing", which points nowhere near the real cause.
#
# The answer itself is only a few hundred tokens; the headroom is for thinking.
MAX_TOKENS = 8000
def provider() -> str | None:
"""Which provider to use, or None if advice is unavailable.
An explicit ADVISOR_PROVIDER wins, so a demo can be pinned to one provider
regardless of which keys happen to be present in the environment.
"""
explicit = os.environ.get("ADVISOR_PROVIDER", "").strip().lower()
if explicit in ("gemini", "anthropic"):
return explicit
if os.environ.get("GEMINI_API_KEY"):
return "gemini"
if os.environ.get("ANTHROPIC_API_KEY"):
return "anthropic"
return None
def model_name() -> str | None:
"""The model that answered, falling back to the one we would ask.
Prefers the ACTUAL model once a call has been made, so a row records what
wrote it rather than what we hoped would write it.
"""
p = provider()
if p is None:
return None
if p in _used_model:
return _used_model[p]
return {"gemini": GEMINI_MODEL, "anthropic": ANTHROPIC_MODEL}[p]
# Images are re-encoded to this before sending. The photos in storage are
# already 800px/q75 (the client downscales before upload), so this is a ceiling
# rather than a resize in the normal case, and it bounds the token cost of a
# photo that arrived by some other route.
MAX_EDGE_PX = 800
JPEG_QUALITY = 75
class PlantAssessment(BaseModel):
"""What we ask the model to return, and what we store.
One schema for both providers, enforced by schema-constrained decoding on
each - so the stored shape is identical whichever wrote it, and switching
provider cannot quietly change the data.
Every field is chosen so that a reader can tell how much to trust it.
`species_confidence` is SELF-REPORTED and labelled as such in the UI - it is
not a measured accuracy and must never be presented as one.
"""
species_common: str | None = Field(
description="Common name of the species, or null if not identifiable."
)
species_scientific: str | None = Field(
description="Binomial scientific name, or null if not identifiable."
)
species_confidence: float = Field(
ge=0.0,
le=1.0,
description="Your own confidence in the species identification, 0 to 1.",
)
health: Literal["healthy", "stressed", "declining", "cannot_tell"] = Field(
description="Overall condition of the plant as far as the photos show it."
)
observations: list[str] = Field(
description="What is actually visible in the photographs. Concrete and "
"specific: leaf colour, leaf loss, wilting, damage, the state of the "
"soil. Do not speculate beyond what the image shows."
)
actions: list[str] = Field(
description="What the planter should do, in plain language, achievable "
"by one person with no equipment and no money."
)
limitations: str = Field(
description="What these photographs could NOT tell you, and what would "
"need to be checked in person."
)
# `limitations` being required is the design, not a formality. An assessment
# that cannot say what it failed to see is indistinguishable from one that saw
# everything, and a planter cannot calibrate how much to trust it.
SYSTEM = """You are advising a smallholder tree planter in Ghana who has \
photographed a young tree (roughly 1-3 years old) they are responsible for \
keeping alive. They are paid when the tree survives, so your advice has real \
consequences for them.
You will receive one to three photographs of the same tree from the same visit: \
a wide shot of the whole tree and its surroundings, a close-up of the trunk, \
and sometimes a close-up of a leaf.
Your job is to identify the species if you can, describe the tree's condition, \
and tell the planter what to do about it.
Rules:
- Describe only what is visible. If the photographs do not show something, say \
so in `limitations` rather than guessing at it.
- Prefer species common in Ghanaian planting schemes where the image supports \
it (Odum/Milicia, Wawa/Triplochiton, Ofram/Terminalia, Mahogany/Khaya, \
Neem/Azadirachta, Ceiba, Mango/Mangifera, Cassia, Acacia, Teak/Tectona), but do \
not force a match. Return null for species rather than a bad guess, and let \
`species_confidence` reflect genuine uncertainty.
- Recommend only actions a person can take with their hands, water, mulch and \
local materials. No paid inputs, no laboratory tests, no equipment. Watering, \
mulching, weeding around the base, removing competing growth, staking, \
protecting from livestock, and clearing termite damage are the realistic \
interventions.
- If the tree looks healthy, say so plainly and give one or two things worth \
keeping up. Do not invent problems.
- Be brief. Two to four observations, two to four actions. This is read on a \
phone, outdoors, by someone standing in front of the tree.
- Never mention verification, scoring, confidence scores or payment. That is a \
different part of this system and not your concern."""
# Which model actually answered, as opposed to which one we asked first. Read by
# split() so the row records the truth rather than the intention - a fallback
# that is not written down is a result nobody can reproduce.
_used_model: dict[str, str] = {}
def _is_rate_limit(e: Exception) -> bool:
"""A quota error, as opposed to a wrong request.
Matched on the response rather than the exception class: the SDK raises the
same ClientError for 404 and 429, and retrying a 404 across three models
just produces the same failure three times.
"""
code = getattr(e, "code", None) or getattr(e, "status_code", None)
if code == 429:
return True
s = str(e)
return "429" in s or "RESOURCE_EXHAUSTED" in s
def _jpeg(img: Image.Image) -> bytes:
"""One photo, normalised to what we actually send.
Storage photos are already 800px/q75 (the client downscales before upload),
so this is a ceiling rather than a resize in the normal case. It bounds the
cost of a photo that arrived by some other route, and it makes the bytes
identical whichever provider is called - so switching provider cannot
silently change what the model saw.
"""
im = img.convert("RGB")
im.thumbnail((MAX_EDGE_PX, MAX_EDGE_PX))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=JPEG_QUALITY)
return buf.getvalue()
def _labelled(wide, close, leaf) -> list[tuple[str, bytes]]:
"""Photos with the labels the model needs to tell them apart.
The labels matter: without them the model cannot distinguish a trunk
close-up from a leaf close-up, and will confidently describe bark as
foliage.
"""
out = []
if wide is not None:
out.append(("Wide shot: the whole tree and its surroundings.", _jpeg(wide)))
if close is not None:
out.append(("Close-up of the trunk.", _jpeg(close)))
if leaf is not None:
out.append(("Close-up of a leaf.", _jpeg(leaf)))
return out
def _assess_gemini(photos, prompt: str) -> PlantAssessment | None:
"""Google's free tier. SDK imported here so a missing package cannot break
scoring, which must never depend on this module."""
from google import genai
from google.genai import types
# DO NOT LET THE SDK RETRY THE 429 FOR US.
#
# By default it retries rate limits internally with backoff, so a call
# against an exhausted model sits there for minutes before raising - a full
# pass appeared to hang rather than fail. We have a better answer than
# waiting: a different model with its own quota. Surface the 429 on the
# first response so the fallback below can act on it immediately.
#
# 5xx and 503 are still worth one retry - those are transient in a way a
# daily quota is not.
client = genai.Client(
api_key=os.environ["GEMINI_API_KEY"],
http_options=types.HttpOptions(
timeout=90_000, # ms
retry_options=types.HttpRetryOptions(
attempts=2, http_status_codes=[500, 502, 503, 504]
),
),
)
parts = []
for label, data in photos:
parts.append(types.Part.from_text(text=label))
parts.append(types.Part.from_bytes(data=data, mime_type="image/jpeg"))
parts.append(types.Part.from_text(text=prompt))
config = types.GenerateContentConfig(
system_instruction=SYSTEM,
max_output_tokens=MAX_TOKENS,
# Schema-constrained decoding, so the answer is valid JSON in the shape
# we asked for rather than prose we have to parse.
response_mime_type="application/json",
response_schema=PlantAssessment,
)
last: Exception | None = None
for i, model in enumerate([GEMINI_MODEL, *GEMINI_FALLBACKS]):
try:
resp = client.models.generate_content(
model=model,
contents=[types.Content(role="user", parts=parts)],
config=config,
)
if i:
# Record it: a run that quietly changed model halfway is a run
# whose output nobody can explain later.
log.warning("gemini fell back to %s after quota on %s", model, GEMINI_MODEL)
_used_model["gemini"] = model
else:
_used_model["gemini"] = model
return resp.parsed
except Exception as e: # noqa: BLE001
last = e
if not _is_rate_limit(e):
raise # a 404 or a bad request will fail identically on every model
log.info("quota exhausted on %s, trying next", model)
raise last if last else AdvisorError("gemini: no model available")
def _assess_anthropic(photos, prompt: str) -> PlantAssessment | None:
from anthropic import Anthropic
blocks: list[dict] = []
for label, data in photos:
blocks.append({"type": "text", "text": label})
blocks.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.standard_b64encode(data).decode("utf-8"),
},
}
)
blocks.append({"type": "text", "text": prompt})
resp = Anthropic().messages.parse(
model=ANTHROPIC_MODEL,
max_tokens=MAX_TOKENS,
system=SYSTEM,
messages=[{"role": "user", "content": blocks}],
output_format=PlantAssessment,
)
return getattr(resp, "parsed_output", None)
def assess(
*,
wide: Image.Image | None = None,
close: Image.Image | None = None,
leaf: Image.Image | None = None,
recorded_species: str | None = None,
strict: bool = False,
) -> PlantAssessment | None:
"""Identify the species and assess the tree's health. None on any failure.
EVERY PHOTO THAT EXISTS IS SENT, and none is required. That is what lets
this run against the pilot check-ins captured before `leaf_photo` existed,
and what lets it improve on its own as leaf photos start arriving - the same
property the plant reference set has, where every verified check-in makes
the next assessment slightly better.
`recorded_species` is what the planter typed at registration. It is passed
as CONTEXT TO DISAGREE WITH, never as an answer to confirm: the whole value
of an independent identification is lost if we tell the model what to say.
"""
photos = _labelled(wide, close, leaf)
if not photos:
log.warning("advisor.assess called with no photographs")
return None
which = provider()
if which is None:
# Not an error. No key configured means advice is switched off, and
# everything else in the system is unaffected.
log.info("no advisory provider configured (set GEMINI_API_KEY)")
return None
prompt = "Identify this tree and assess its condition."
if recorded_species:
# Framed to invite contradiction. "The planter recorded X" is a claim to
# test; "this is an X" would be an instruction to agree.
prompt += (
f"\n\nThe planter recorded this tree's species as '{recorded_species}' "
"when they registered it. Treat that as an unverified claim, not as "
"the answer. If the photographs show something else, say so."
)
try:
parsed = (
_assess_gemini(photos, prompt)
if which == "gemini"
else _assess_anthropic(photos, prompt)
)
except Exception as e: # noqa: BLE001 - advisory only, must never break a caller
log.exception("advisory assessment failed (%s)", which)
# A DEAD PROVIDER MUST NOT LOOK LIKE A QUIET SKIP.
#
# This returned None on failure, and the pilot tool counted None as
# "skipped - no decline detected, or the model declined". A 404 on a
# retired model id therefore reported as a clean run over every
# check-in, which is precisely the false pass this project has been
# caught by three times before.
#
# The endpoint still fails soft (strict=False) because a broken advisory
# call must never take verification down. Batch tools pass strict=True,
# because a tool that cannot tell "nothing to do" from "nothing worked"
# is worse than no tool.
if strict:
raise AdvisorError(f"{which}: {e}") from e
return None
# A refusal or a safety block is a successful call with nothing parsed.
# Guard before reading rather than raising out of a function documented
# never to raise.
if parsed is None:
log.warning("advisory assessment returned no parsed output (%s)", which)
if strict:
raise AdvisorError(
f"{which}: returned no parsed output - most likely the response "
"was truncated, check finish_reason and MAX_TOKENS"
)
return None
return parsed
def split(assessment: PlantAssessment) -> tuple[dict, dict]:
"""One assessment -> the two columns it is stored in.
Species and advice are separated in the database because they have different
futures: species is a candidate verification signal (a species that changes
between visits is evidence of a swapped tree), and advice never will be.
Storing them together would make that separation a refactor later.
"""
species = {
"common": assessment.species_common,
"scientific": assessment.species_scientific,
# Named to make its nature unmissable at every layer. This is the
# model's opinion of itself, not a measured accuracy, and calling the
# field `confidence` next to a column that genuinely IS a measured
# confidence would be actively misleading.
"self_reported_confidence": assessment.species_confidence,
"model": model_name(),
}
advice = {
"health": assessment.health,
"observations": assessment.observations,
"actions": assessment.actions,
"limitations": assessment.limitations,
"model": model_name(),
"advisory_only": True,
}
return species, advice
# ---------------------------------------------------------------------------
# When to spend a call
# ---------------------------------------------------------------------------
#
# THE DESIGNED POLICY IS DECLINE-ONLY. The deterministic canopy signal in
# scoring.score_growth already flags a tree that has lost more than half its
# canopy since the last visit - that is the dying-tree detector, it costs
# nothing, and it is measured. Calling an LLM only when that fires is what keeps
# the per-tree cost defensible at scale:
#
# ~3 cents a call. One million trees checked four times a year is
# ~$120k on every visit, ~$6-12k on decline only.
#
# WHY IT IS CURRENTLY TRUE FOR EVERY CHECK-IN ANYWAY. The pilot is 6 trees and
# 9 check-ins, and most of those are first visits - where score_growth returns a
# neutral 0.5 because there is no previous canopy to compare against. A
# decline-only trigger would fire zero times on the data we actually have, and
# an advisory layer with nothing to advise on is not a feature.
#
# So this is a policy constant with both branches live, rather than a hardcoded
# call site. Both statements are true and both are defensible: we assess every
# visit at pilot scale, and the design for scale is decline-only.
ADVISE_ON_EVERY_CHECKIN = True
# Below this growth score, the tree is declining enough to be worth advice.
# Matches the 0.15 that score_growth assigns to >50% canopy loss.
DECLINE_GROWTH_SCORE = 0.2
def should_advise(checkin: dict) -> bool:
"""Is this check-in worth spending an API call on?"""
if ADVISE_ON_EVERY_CHECKIN:
return True
growth = ((checkin.get("signals") or {}).get("scores") or {}).get("growth") or {}
score = growth.get("score")
# An unscored check-in has no decline evidence either way. Advising on it
# would quietly restore "every check-in" through the back door.
return score is not None and score <= DECLINE_GROWTH_SCORE
def advise_checkin(checkin_id: str, strict: bool = False) -> dict | None:
"""Assess one check-in and store the result. None if nothing was written.
Fetches its own photos from storage, exactly as scoring does, so the request
body cannot influence what gets assessed.
"""
from . import store # local: keeps the module importable without credentials
row = store.get_checkin(checkin_id)
if row is None:
raise LookupError(f"No check-in {checkin_id}")
if not should_advise(row):
log.info("skipping advice for %s: no decline detected", checkin_id)
return None
tree = store.get_tree(row["tree_id"])
def _photo(path: str | None) -> Image.Image | None:
"""A missing or unreadable photo costs us that photo, never the call."""
if not path:
return None
try:
return store.download_image(path)
except Exception: # noqa: BLE001
log.warning("could not download %s", path, exc_info=True)
return None
photos = dict(
wide=_photo(row.get("wide_photo")),
close=_photo(row.get("close_photo")),
leaf=_photo(row.get("leaf_photo")),
recorded_species=(tree or {}).get("species"),
)
# ONE RETRY, because a lost assessment is silent.
#
# assess() already walks the model fallback chain on a quota error, but a
# 504 DEADLINE_EXCEEDED is different: the model simply took too long on that
# attempt, and the same model usually answers on the next one. Without this
# the visit keeps its verdict and quietly carries no advice, which is exactly
# the failure the planter reported - the feature working and being invisible.
#
# Two attempts, not more: this runs in a background thread per check-in, and
# a model that fails twice is not going to succeed on the fifth try.
assessment = None
for attempt in (1, 2):
assessment = assess(**photos, strict=strict and attempt == 2)
if assessment is not None:
break
if attempt == 1:
log.warning("advice attempt 1 failed for %s, retrying once", checkin_id)
time.sleep(2)
if assessment is None:
return None
species, advice = split(assessment)
store.write_advice(checkin_id, species, advice)
log.info(
"advised %s -> %s (species: %s)",
checkin_id,
assessment.health,
assessment.species_common,
)
return {"species_guess": species, "advice": advice}
|