Spaces:
Running
Running
| """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} | |