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}