A small Python package that turns the knowledge base into a working photo-in, diagnosis-out pipeline. The core retrieval has zero dependencies and runs offline; only the Claude calls need the network.
pip install anthropic # only needed for the diagnosis half
export ANTHROPIC_API_KEY=sk-ant-...
export ZIMKB_ROOT=./zimbabwe-crop-disease-kb
python -m zimkb stats
python -m zimkb search "grey rectangular lesions between the veins on lower leaves" --crop maize
python -m zimkb diagnose photo.jpg --crop maizefrom zimkb import Diagnoser
dx = Diagnoser()
result = dx.diagnose_image("leaf.jpg", crop="maize",
farmer_context="Pfumvudza plot, Mashonaland East, planted late November")
print(result["farmer_message"])zimkb is the retrieval and diagnosis layer. The corpus it reads is a separate artifact and is
not included in this repository.
The knowledge base is 281 condition records across five crops — tobacco 65, bean 65, maize 58, wheat 49, sorghum 44 — pre-chunked into 1,850 passages. The category mix is a deliberate design decision rather than an accident of sourcing: disease 138, insect pest 55, nutrient deficiency 45, abiotic disorder 33, storage pest 6, parasitic weed 2, vertebrate pest 2. A third of it is not a pathogen at all, because a third of the wrong answers a vision model gives are pathogens that were really hunger, drought, frost, herbicide drift or lightning.
Each record carries a forensic visual_diagnosis (lesion geometry, colour zoning, size in
millimetres, margins and halos, texture, leaf surface, canopy position, distribution, progression),
a diagnostic_field_test, a look_alikes differential layer, vision_tags and
farmer_query_phrases for retrieval, management split into a chemical path and a smallholder
path, what_not_to_do, a safety block, and a per-record confidence flag for where the
underlying research was thin.
Why it is not in this repo. The corpus was compiled from third-party extension and research
material — CABI Plantwise Plus, CIMMYT, ICRISAT, CIAT/PABRA, FAO, Kutsaga and the Tobacco Research
Board, AGRITEX, Seed Co, ARC South Africa, Grain SA, university extension and peer-reviewed
Zimbabwe survey literature. Redistributing that as a bundle is a licensing question rather than a
technical one, so it is held separately and pointed at with ZIMKB_ROOT.
Point ZIMKB_ROOT at any directory with the same shape and the package works unchanged. Run
python -m zimkb stats to check what it loaded.
photo ──► Claude (vision) ──► forensic symptom description, NO disease name
│
▼
stage 1: match against `visual` chunks
filtered to the crop ──► top-k candidates
│
▼
stage 2: pull differential + management + safety
for those candidates AND their look-alikes
— by exact metadata lookup, not similarity
│
▼
Claude (reasoning) ──► diagnosis + prescription
Two decisions in there do most of the work.
The vision step is forbidden from naming a disease. If Claude writes "this looks like grey leaf spot" while describing the photo, every downstream step is anchored on that guess and retrieval just confirms it. The prompt in prompts.py pushes hard against this — describe the rectangular grey-tan lesion bounded by the veins, don't name it. The naming happens later, from retrieved evidence.
Stage 2 is a metadata lookup, not a second search. You don't want the differential passage for a condition retrieved by vector chance — you want it because that condition is a candidate. Single-stage similarity search returns five descriptions that all sound plausible and gives the model nothing to choose between them. Pulling each candidate's differential section, plus the records it names as look-alikes, is what forces an actual decision. This is the single biggest accuracy lever in the whole pipeline.
Fifteen realistic symptom descriptions, written the way a field officer would phrase them, scored against the condition they actually describe:
| Pure BM25, no embeddings | |
|---|---|
| top-1 | 13 / 15 |
| top-3 | 15 / 15 |
The two top-1 misses — Aspergillus ear rot ranked behind Diplodia, Granville wilt behind Fusarium wilt — are both cases where the corpus itself says the two are hard to separate visually. Both landed at rank 2, so Claude receives both records with their differentials and the discriminator that separates them. That is the pipeline working as designed, not failing.
BM25 does this well because the corpus carries curated vision_tags per record and the vision prompt elicits the same terminology the KB uses. Dense embeddings are genuinely optional here.
Anthropic has no embeddings endpoint. Claude does the vision and the reasoning; the vectors, if you want them, come from somewhere else. embeddings.py ships four adapters behind one .encode(texts) interface:
LocalEmbedder — sentence-transformers, ~90MB, free, offline. The right default for a Zimbabwe deployment where connectivity is patchy and per-call cost matters. VoyageEmbedder — what Anthropic points to. OpenAIEmbedder, CohereEmbedder — if already in your stack.
Add dense retrieval when farmers type loose paraphrase ("leaves going brown at the edges") rather than field terminology. Blend with alpha: 1.0 pure dense, 0.0 pure lexical, 0.5 default.
from zimkb import load, Retriever
from zimkb.embeddings import LocalEmbedder
r = Retriever(load(), embedder=LocalEmbedder())
r.build_dense("maize") # once at startup, then pickle it
r.candidates("bottom leaves yellowing from the tip", crop="maize", alpha=0.5)Since you're deciding that separately — kb.embeddable() hands back (chunk_id, text, metadata) triples with list fields already flattened, because most vector stores reject list-valued metadata.
rows = kb.embeddable() # 1,850 rows
ids, texts, metas = zip(*rows)For stage 1, filter on metadata["section"] == "visual" and metadata["crop"]. For stage 2, query by condition_id — no vectors involved. examples/export_to_vectorstore.py has working snippets for Chroma and pgvector and notes on the rest. python -m zimkb export dumps the same thing as JSONL.
If you keep the whole KB in memory instead, it's about 25MB and BM25 queries take roughly two milliseconds. At this corpus size a vector database is a convenience, not a necessity.
loader.py reads the KB and normalises crop names, including Shona and Ndebele ones — chibage and umumbu both resolve to maize, which matters if farmers type in their own language. bm25.py is a 50-line Okapi BM25 with domain stopwords stripped. retrieve.py is the two-stage retriever. prompts.py holds both system prompts. diagnose.py is the Claude pipeline with tolerant JSON parsing, because models occasionally wrap JSON in a fence. cli.py gives you stats, search, diagnose, export.
Roughly one vision call (~1.5k output tokens) plus one reasoning call (~15-25k input, ~2k output) per diagnosis, so a few US cents on Sonnet. Two levers if that matters at scale: run the vision step on Haiku and keep Sonnet for the reasoning — the vision step is description, not judgement, and the cheaper model handles it — and drop include_reference=False when you don't need the natural-region and calendar framing, which is most of the input tokens.
/search in examples/server.py is a free path: text query, no image, no Claude call. Useful for a "look it up" feature and for testing retrieval changes without burning API budget.
Log result["_retrieval"]["candidates"] from day one. When a diagnosis is wrong, that tells you instantly whether retrieval missed the condition entirely or whether it was in context and Claude chose badly. Those are completely different fixes — the first is a vision_tags gap in the record, the second is a prompt or a missing discriminator.
Feed farmer_context whatever you know: region, planting date, whether it's a Pfumvudza plot, what grew there last season, recent weather. The prompt uses it, and it changes the answer — grey leaf spot in December is a different conversation from grey leaf spot in March, and continuous maize on the same basins shifts the priors.
Trust ask_farmer_for. When the model says it needs the leaf underside or a split stem, that's the corpus's diagnostic_field_test surfacing, and a second photo is far cheaper than a wrong prescription.
Read KNOWN_ISSUES.md in the KB. The short version: pesticide registrations drift and the banned-list validator needs re-running against a current DR&SS list; the Shona and Ndebele glossary needs native-speaker validation before you surface local terms; and the model should route suspected quarantine pests and unexplained whole-field collapses to AGRITEX rather than to a spray can. The diagnosis prompt already instructs all three, but instructions are not enforcement — test them.