Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions components/bed-sense-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,14 @@ function ChemicalCard({ text, words, advice }: { text: Copy; words: UiWords; adv
<span className="mt-0.5 block text-sm text-mute">
<span className="font-semibold text-sun">{text.quality[offer.quality]}</span> · {offer.brand} · {offer.pack}
</span>
<a
href={offer.productLink.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-sm font-semibold text-leaf underline decoration-leaf/50 underline-offset-2"
>
{offer.productLink.curated ? text.productLinkView : text.productLinkSearch}
</a>
</li>
))}
</ul>
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ flowchart LR
4. The segmentation model id is `sam-3.1`. Without a name it prompts a generic `vegetable plant`, plus `yellow leaf` and `weed`. When a name is given (correction re-run), a known crop uses its concept phrase, such as `chili pepper plant` or `onion plant`, and any other name uses a phrase such as `tomato plant`. It does not probe onion, beetroot, and chili on every photo, so a miss on another bed cannot become a note. If every prompt fails or returns nothing, BedSense still sends the photo to Muse. Requests use `metadata.mask_encoding=one_bit` and `metadata.include_confidence=true`. Masks are decoded with `@meta-sam/parser` and sent to the phone as a small grid drawn over the photo. A generic plant mask that sits on onion, beetroot, or chili is dropped so those crops keep their own outline.
5. A photo is one bed, and it can be any farm plant. Onion, beetroot, and chili each grow on their own bed. Onion and beetroot are never on the same bed, and chili is a separate bed as well. The code does not describe where those beds are. If more than one of those three is marked in a frame, crop focus is `mixed`: an unusual multi-crop frame, not the planting pattern. Onion area is weighted slightly higher, so when the scores are close the familiar primary mark stays onion. Muse names the plant in the photo; a farmer-given name (correction re-run) is a hint the photo can overrule.
6. Muse Spark sees the photo, any farmer-given name, the counts, and any optional context. Observations describe only what stands out for plants actually in the photo. They do not name a crop that was not marked. A failed segmentation prompt is not shown. A JPEG that is heavily blurred, crushed into low contrast, split by extreme shadow and blown highlights, or a wide view with open sky and no strong SAM mark, is not diagnosed. Hard sun on a plant SAM has already locked is still checked. The phone asks the farmer to move closer, turn so the sun is at their back, or take a clearer photo. In a batch of angles, only the usable photos go to Muse, and the result names the image that was left out. A close photo with no marks still reaches Muse. Empty marks do not skip the reading: if the photo or the notes show a symptom (curl, deformity, spots, blisters or raised bumps, insects, chewing or holes, webbing, sticky or sooty coating, mosaic, wilt, yellowing), the result includes likely causes, mitigation steps, and three checks about that symptom. Every rendered reading ends in one of three outcomes: chemicals when a spray is the usual next step, researched mitigation steps when it is not, or an explicit insufficient-evidence state (`needsInfo`) when the photo does not separate the likely causes — no chemical is named, the safe steps still show, and the follow-up names the missing angle or fact. Every reading also carries a guaranteed follow-up (`contextAsk`): what was already sprayed or fed when a chemical is named, the discriminating fact when only mitigation is given, the blocking ask when more evidence is needed, or the plant-age default on a healthy bed. Under the three next checks, a follow-up Q&A box (`POST /api/ask`, `lib/server/ask.ts`) lets the farmer ask about the feedback without re-running the check: it is a text-only Muse call grounded in the rendered result (plant, causes, mitigations, checks, chemicals, `needsInfo`), never calls SAM or `/api/analyze`, strips any numeric rate from answers, answers locally from the reading in demo mode, and keeps the thread on the result (`thread`) so it persists with the saved check. The generic soil / remove-leaves / photograph-again backup is only for a reading with no symptom. A known spelling variant, such as chilly for chili, is folded to the canonical name in English or Kiswahili in the cards, not only in the title. Muse returns JSON with `detectedPlant`, `symptom`, observations, `likelyCauses`, `mitigations`, exactly three `actions`, `contextAsk`, `sprayIndicated`, `diseaseIndicated`, `suspect`, and `chemicals`. A cut-off or prose reply that still describes the symptom is kept. Chemicals and Kenyan shop bands are added only when a spray is the usual next step (spots, insects already seen, or curl/deformity where the reading also shows insects). Curl alone stays on cultural steps. The note must not invent a numeric spray rate. When Muse names a problem it cannot match to a product, the server researches the suspect in a local knowledge base (`lib/research.ts`) and adds matching chemicals, fertilizer, and cultural practices, each with example shops. A Kiswahili reading also repairs English drift in the model sentences with a local glossary (`lib/translate.ts`), so the advice reads as Swahili without a second model call. The phone shows a progress card while SAM and Muse run.
7. Recommended actives are matched to `lib/agrovet-catalog.ts`, a curated list of fungicides, insecticides, and fertilizers sold through Kenyan agrovets. Each match adds example shops (name, kind, city or chain) and KES price bands for economy, mid, and premium packs. Those bands are demo ranges, not a live quote. `liveKenyaShopOffers` in `lib/agrovet.ts` is the live-search stub: it returns `null`, and the catalog is used. No paid agrovet API is called, which keeps the lookup on Vercel Hobby. `MOCK_META=1` runs the same matcher, so the demo shows chemicals, shops, and KES bands without calling Meta.
7. Recommended actives are matched to `lib/agrovet-catalog.ts`, a curated list of fungicides, insecticides, and fertilizers sold through Kenyan agrovets. Each match adds example shops (name, kind, city or chain) and KES price bands for economy, mid, and premium packs. Those bands are demo ranges, not a live quote. `liveKenyaShopOffers` in `lib/agrovet.ts` is the live-search stub: it returns `null`, and the catalog is used. No paid agrovet API is called, which keeps the lookup on Vercel Hobby. `MOCK_META=1` runs the same matcher, so the demo shows chemicals, shops, and KES bands without calling Meta. Each offer also carries a product link: a Kenya-scoped search for the brand and pack, or a curated product page when the offer sets `productUrl`. Only add a `productUrl` after opening it in a browser and confirming it shows that brand and pack; retailer and manufacturer URLs guessed without checking have returned empty searches and 404s, and a wrong-product link costs more trust than a search link. Product photos are not shown: there are no licensed packshots, and hotlinking retailer images breaks without notice.

## Scripts

Expand Down
12 changes: 12 additions & 0 deletions lib/agrovet-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ export type CatalogOffer = {
place: string;
priceMinKes: number;
priceMaxKes: number;
/**
* Optional hand-verified product page for this brand and pack. Only add a
* URL after opening it in a browser and confirming it shows this product;
* offers without one fall back to a Kenya-scoped search link.
*/
productUrl?: string;
};

/** Kenya-scoped search for one brand and pack, used when no product page is verified. */
export function searchUrlFor(offer: Pick<CatalogOffer, "brand" | "pack">): string {
const query = `"${offer.brand}" ${offer.pack} buy Kenya agrovet`;
return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
}

export type CatalogKind = "chemical" | "fertilizer";

export type CatalogEntry = {
Expand Down
5 changes: 4 additions & 1 deletion lib/agrovet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AGROVET_CATALOG, type CatalogEntry } from "@/lib/agrovet-catalog";
import { AGROVET_CATALOG, searchUrlFor, type CatalogEntry } from "@/lib/agrovet-catalog";
import { copy } from "@/lib/copy";
import { containsDosage } from "@/lib/triage";
import { repairSentence, repairSuspect } from "@/lib/translate";
Expand Down Expand Up @@ -81,6 +81,9 @@ function catalogOffers(entry: CatalogEntry, locale: Locale): ShopOffer[] {
place: offer.place,
priceMinKes: offer.priceMinKes,
priceMaxKes: offer.priceMaxKes,
productLink: offer.productUrl
? { url: offer.productUrl, curated: true }
: { url: searchUrlFor(offer), curated: false },
}));
}

Expand Down
8 changes: 8 additions & 0 deletions lib/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export type Copy = {
chemicalsSuspect: string;
chemicalsWhere: string;
chemicalsNoShop: string;
/** Link to a verified product page for one offer. */
productLinkView: string;
/** Link to a Kenya-scoped search for one offer, when no product page is verified. */
productLinkSearch: string;
chemicalsSuspectFallback: string;
chemicalClassFallback: string;
chemicalUseFallback: string;
Expand Down Expand Up @@ -236,6 +240,8 @@ const en: Copy = {
chemicalsSuspect: "Suspected problem",
chemicalsWhere: "Where to buy in Kenya",
chemicalsNoShop: "This active ingredient is not in the Kenya catalog yet. Ask an agrovet to match the name.",
productLinkView: "View this product",
productLinkSearch: "Search for this product",
chemicalsSuspectFallback: "A disease or pest looks likely.",
chemicalClassFallback: "crop protection product",
chemicalUseFallback: "Typical use for this active ingredient. The rate must follow the product label.",
Expand Down Expand Up @@ -463,6 +469,8 @@ const sw: Copy = {
chemicalsSuspect: "Tatizo linaloshukiwa",
chemicalsWhere: "Unapoweza kununua Kenya",
chemicalsNoShop: "Dawa hii bado haiko kwenye orodha yetu ya Kenya. Muulize muuzaji wa agrovet akupe yenye kiambato hicho.",
productLinkView: "Tazama bidhaa hii",
productLinkSearch: "Tafuta bidhaa hii",
chemicalsSuspectFallback: "Huenda kuna ugonjwa au wadudu.",
chemicalClassFallback: "dawa ya kulinda mimea",
chemicalUseFallback: "Matumizi ya kawaida ya dawa hii. Fuata kipimo kilicho kwenye kibandiko.",
Expand Down
2 changes: 2 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type ShopOffer = {
place: string;
priceMinKes: number;
priceMaxKes: number;
/** Where the farmer can look this exact brand and pack up. A curated product page when one is verified, otherwise a Kenya-scoped search. */
productLink: { url: string; curated: boolean };
};

export type ChemicalProduct = {
Expand Down
25 changes: 24 additions & 1 deletion tests/agrovet.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { AGROVET_CATALOG } from "@/lib/agrovet-catalog";
import { AGROVET_CATALOG, searchUrlFor } from "@/lib/agrovet-catalog";
import { adviseChemicals, liveKenyaShopOffers, matchCatalog } from "@/lib/agrovet";
import { containsDosage } from "@/lib/triage";
import { QUALITY_BANDS } from "@/lib/types";
Expand Down Expand Up @@ -61,6 +61,29 @@ describe("Kenya agrovet catalog", () => {
).toBeNull();
});

it("gives every offer a product link: curated page when set, Kenya search otherwise", () => {
for (const entry of AGROVET_CATALOG) {
for (const offer of entry.offers) {
if (offer.productUrl) {
expect(offer.productUrl).toMatch(/^https:\/\//);
}
const url = new URL(searchUrlFor(offer));
expect(url.protocol).toBe("https:");
expect(url.searchParams.get("q")).toContain(offer.brand);
expect(url.searchParams.get("q")).toContain("Kenya");
}
}
const advice = adviseChemicals({
locale: "en",
diseaseIndicated: true,
suspect: "bacterial spot",
chemicals: [{ activeIngredient: "copper oxychloride" }],
});
const offer = advice?.products[0]?.offers[0];
expect(offer?.productLink.url).toContain(encodeURIComponent("Copper oxychloride 50 WP"));
expect(offer?.productLink.curated).toBe(false);
});

it("uses Kiswahili shop kinds from the same catalog", () => {
const advice = adviseChemicals({
locale: "sw",
Expand Down
Loading