This commit is contained in:
2026-06-06 15:09:46 -04:00
parent 78220d3568
commit 06295c83ca
56 changed files with 12018 additions and 440 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE `diseases` ADD `prevalence` text DEFAULT 'uncommon' NOT NULL;--> statement-breakpoint
CREATE INDEX `idx_diseases_prevalence` ON `diseases` (`prevalence`);

View File

@@ -0,0 +1,7 @@
CREATE TABLE `plant_views` (
`plant_id` text PRIMARY KEY NOT NULL,
`view_count` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`plant_id`) REFERENCES `plants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `idx_plant_views_count` ON `plant_views` (`view_count`);

View File

@@ -0,0 +1,347 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7a3efb1c-d2e3-4f56-a789-0b1234567890",
"prevId": "6f2de82b-c1f9-42de-b03c-1c1f0c02b7c9",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prevalence": {
"name": "prevalence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'uncommon'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": ["plant_id"],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": ["causal_agent_type"],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": ["severity"],
"isUnique": false
},
"idx_diseases_prevalence": {
"name": "idx_diseases_prevalence",
"columns": ["prevalence"],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": ["plant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": ["category"],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": ["common_name"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,410 @@
{
"version": "6",
"dialect": "sqlite",
"id": "04ff83bd-e207-44d3-b8b7-8f82157bbeb8",
"prevId": "7a3efb1c-d2e3-4f56-a789-0b1234567890",
"tables": {
"diseases": {
"name": "diseases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"causal_agent_type": {
"name": "causal_agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"symptoms": {
"name": "symptoms",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"causes": {
"name": "causes",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"treatment": {
"name": "treatment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevention": {
"name": "prevention",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"lookalike_ids": {
"name": "lookalike_ids",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"prevalence": {
"name": "prevalence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'uncommon'"
},
"severity": {
"name": "severity",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_diseases_plant_id": {
"name": "idx_diseases_plant_id",
"columns": [
"plant_id"
],
"isUnique": false
},
"idx_diseases_causal_agent": {
"name": "idx_diseases_causal_agent",
"columns": [
"causal_agent_type"
],
"isUnique": false
},
"idx_diseases_severity": {
"name": "idx_diseases_severity",
"columns": [
"severity"
],
"isUnique": false
},
"idx_diseases_prevalence": {
"name": "idx_diseases_prevalence",
"columns": [
"prevalence"
],
"isUnique": false
}
},
"foreignKeys": {
"diseases_plant_id_plants_id_fk": {
"name": "diseases_plant_id_plants_id_fk",
"tableFrom": "diseases",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plant_views": {
"name": "plant_views",
"columns": {
"plant_id": {
"name": "plant_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"idx_plant_views_count": {
"name": "idx_plant_views_count",
"columns": [
"view_count"
],
"isUnique": false
}
},
"foreignKeys": {
"plant_views_plant_id_plants_id_fk": {
"name": "plant_views_plant_id_plants_id_fk",
"tableFrom": "plant_views",
"tableTo": "plants",
"columnsFrom": [
"plant_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"plants": {
"name": "plants",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"common_name": {
"name": "common_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"scientific_name": {
"name": "scientific_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"family": {
"name": "family",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"care_summary": {
"name": "care_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {
"idx_plants_category": {
"name": "idx_plants_category",
"columns": [
"category"
],
"isUnique": false
},
"idx_plants_common_name": {
"name": "idx_plants_common_name",
"columns": [
"common_name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scrape_sources": {
"name": "scrape_sources",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_type": {
"name": "source_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entries_count": {
"name": "entries_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,20 @@
"when": 1780710023177,
"tag": "0001_add-disease-images",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1749268800000,
"tag": "0002_add-prevalence",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1749268800000,
"tag": "0003_giant_toad",
"breakpoints": true
}
]
}
}

View File

@@ -1,6 +1,18 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow remote images from Wikimedia Commons
images: {
remotePatterns: [
{
protocol: "https",
hostname: "upload.wikimedia.org",
port: "",
pathname: "/wikipedia/commons/**",
search: "",
},
],
},
// Turbopack config (Next.js 16 default)
turbopack: {
resolveAlias: {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@
},
"dependencies": {
"@libsql/client": "^0.17.3",
"@mudbill/duckduckgo-images-api": "^2.0.1",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-node": "^4.22.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"next": "16.2.7",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,655 @@
{
"processedIds": [
"cucumber-horned-phytophthora-blight-cucurbits",
"crabapple-fire-blight",
"serviceberry-fire-blight",
"chokecherry-fire-blight",
"soybean-peanut-mottle",
"sweet-potato-little-leaf-proliferation-disease",
"sweet-potato-internal-cork",
"winter-squash-viral-leaf-curl",
"acorn-squash-viral-leaf-curl",
"butternut-squash-viral-leaf-curl",
"monstera-viral-leaf-curl",
"monstera-wood-rot-decay",
"pothos-downy-mildew-generic",
"pothos-viral-leaf-curl",
"pothos-wood-rot-decay",
"peace-lily-bacterial-soft-rot",
"peace-lily-downy-mildew-generic",
"peace-lily-viral-leaf-curl",
"peace-lily-wood-rot-decay",
"philodendron-bacterial-soft-rot",
"philodendron-downy-mildew-generic",
"philodendron-viral-leaf-curl",
"philodendron-wood-rot-decay",
"anthurium-bacterial-soft-rot",
"anthurium-downy-mildew-generic",
"anthurium-viral-leaf-curl",
"anthurium-wood-rot-decay",
"alocasia-bacterial-soft-rot",
"alocasia-downy-mildew-generic",
"alocasia-viral-leaf-curl",
"alocasia-wood-rot-decay",
"caladium-bacterial-soft-rot",
"caladium-downy-mildew-generic",
"caladium-viral-leaf-curl",
"caladium-wood-rot-decay",
"aglaonema-bacterial-soft-rot",
"aglaonema-downy-mildew-generic",
"aglaonema-viral-leaf-curl",
"aglaonema-wood-rot-decay",
"dieffenbachia-bacterial-soft-rot",
"dieffenbachia-downy-mildew-generic",
"dieffenbachia-viral-leaf-curl",
"dieffenbachia-wood-rot-decay",
"spathiphyllum-bacterial-soft-rot",
"spathiphyllum-downy-mildew-generic",
"spathiphyllum-viral-leaf-curl",
"spathiphyllum-wood-rot-decay",
"asparagus-bacterial-soft-rot",
"asparagus-downy-mildew-generic",
"asparagus-viral-leaf-curl",
"asparagus-wood-rot-decay",
"snake-plant-bacterial-soft-rot",
"snake-plant-downy-mildew-generic",
"snake-plant-viral-leaf-curl",
"snake-plant-wood-rot-decay",
"yucca-bacterial-soft-rot",
"yucca-downy-mildew-generic",
"yucca-viral-leaf-curl",
"yucca-wood-rot-decay",
"dracaena-bacterial-soft-rot",
"dracaena-downy-mildew-generic",
"dracaena-viral-leaf-curl",
"dracaena-wood-rot-decay",
"lily-of-the-valley-bacterial-soft-rot",
"lily-of-the-valley-downy-mildew-generic",
"lily-of-the-valley-viral-leaf-curl",
"lily-of-the-valley-wood-rot-decay",
"hosta-bacterial-soft-rot",
"hosta-downy-mildew-generic",
"hosta-viral-leaf-curl",
"hosta-wood-rot-decay",
"orchid-phalaenopsis-bacterial-soft-rot",
"orchid-phalaenopsis-downy-mildew-generic",
"orchid-phalaenopsis-viral-leaf-curl",
"orchid-phalaenopsis-wood-rot-decay",
"orchid-cattleya-bacterial-soft-rot",
"orchid-cattleya-downy-mildew-generic",
"orchid-cattleya-viral-leaf-curl",
"orchid-cattleya-wood-rot-decay",
"orchid-dendrobium-bacterial-soft-rot",
"orchid-dendrobium-downy-mildew-generic",
"orchid-dendrobium-viral-leaf-curl",
"orchid-dendrobium-wood-rot-decay",
"orchid-oncidium-bacterial-soft-rot",
"orchid-oncidium-downy-mildew-generic",
"orchid-oncidium-viral-leaf-curl",
"orchid-oncidium-wood-rot-decay",
"vanilla-bacterial-soft-rot",
"vanilla-downy-mildew-generic",
"vanilla-viral-leaf-curl",
"vanilla-wood-rot-decay",
"prickly-pear-bacterial-soft-rot",
"prickly-pear-downy-mildew-generic",
"prickly-pear-viral-leaf-curl",
"prickly-pear-wood-rot-decay",
"barrel-cactus-bacterial-soft-rot",
"barrel-cactus-downy-mildew-generic",
"barrel-cactus-viral-leaf-curl",
"barrel-cactus-wood-rot-decay",
"christmas-cactus-bacterial-soft-rot",
"christmas-cactus-downy-mildew-generic",
"christmas-cactus-viral-leaf-curl",
"christmas-cactus-wood-rot-decay",
"saguaro-bacterial-soft-rot",
"saguaro-downy-mildew-generic",
"saguaro-viral-leaf-curl",
"saguaro-wood-rot-decay",
"aloe-vera-bacterial-soft-rot",
"aloe-vera-downy-mildew-generic",
"aloe-vera-viral-leaf-curl",
"aloe-vera-wood-rot-decay",
"agave-bacterial-soft-rot",
"agave-downy-mildew-generic",
"agave-viral-leaf-curl",
"agave-wood-rot-decay",
"echeveria-bacterial-soft-rot",
"echeveria-downy-mildew-generic",
"echeveria-viral-leaf-curl",
"echeveria-wood-rot-decay",
"jade-plant-bacterial-soft-rot",
"jade-plant-downy-mildew-generic",
"jade-plant-viral-leaf-curl",
"jade-plant-wood-rot-decay",
"sedum-bacterial-soft-rot",
"sedum-downy-mildew-generic",
"sedum-viral-leaf-curl",
"sedum-wood-rot-decay",
"haworthia-bacterial-soft-rot",
"haworthia-downy-mildew-generic",
"haworthia-viral-leaf-curl",
"haworthia-wood-rot-decay",
"poinsettia-bacterial-soft-rot",
"poinsettia-downy-mildew-generic",
"poinsettia-viral-leaf-curl",
"poinsettia-wood-rot-decay",
"cassava-bacterial-soft-rot",
"cassava-downy-mildew-generic",
"cassava-viral-leaf-curl",
"cassava-wood-rot-decay",
"castor-bean-bacterial-soft-rot",
"castor-bean-downy-mildew-generic",
"castor-bean-viral-leaf-curl",
"castor-bean-wood-rot-decay",
"crown-of-thorns-bacterial-soft-rot",
"crown-of-thorns-downy-mildew-generic",
"crown-of-thorns-viral-leaf-curl",
"crown-of-thorns-wood-rot-decay",
"orange-bacterial-soft-rot",
"orange-downy-mildew-generic",
"orange-viral-leaf-curl",
"orange-wood-rot-decay",
"lemon-bacterial-soft-rot",
"lemon-downy-mildew-generic",
"lemon-viral-leaf-curl",
"lemon-wood-rot-decay",
"lime-bacterial-soft-rot",
"lime-downy-mildew-generic",
"lime-viral-leaf-curl",
"lime-wood-rot-decay",
"grapefruit-bacterial-soft-rot",
"grapefruit-downy-mildew-generic",
"grapefruit-viral-leaf-curl",
"grapefruit-wood-rot-decay",
"mandarin-bacterial-soft-rot",
"mandarin-downy-mildew-generic",
"mandarin-viral-leaf-curl",
"mandarin-wood-rot-decay",
"kumquat-bacterial-soft-rot",
"kumquat-downy-mildew-generic",
"kumquat-viral-leaf-curl",
"kumquat-wood-rot-decay",
"grape-bacterial-soft-rot",
"grape-downy-mildew-generic",
"grape-viral-leaf-curl",
"grape-wood-rot-decay",
"muscadine-bacterial-soft-rot",
"muscadine-downy-mildew-generic",
"muscadine-viral-leaf-curl",
"muscadine-wood-rot-decay",
"banana-bacterial-soft-rot",
"banana-downy-mildew-generic",
"banana-viral-leaf-curl",
"banana-wood-rot-decay",
"plantain-bacterial-soft-rot",
"plantain-downy-mildew-generic",
"plantain-viral-leaf-curl",
"plantain-wood-rot-decay",
"bird-of-paradise-bacterial-soft-rot",
"bird-of-paradise-downy-mildew-generic",
"bird-of-paradise-viral-leaf-curl",
"bird-of-paradise-wood-rot-decay",
"avocado-bacterial-soft-rot",
"avocado-downy-mildew-generic",
"avocado-viral-leaf-curl",
"avocado-wood-rot-decay",
"cinnamon-bacterial-soft-rot",
"cinnamon-downy-mildew-generic",
"cinnamon-viral-leaf-curl",
"cinnamon-wood-rot-decay",
"bay-laurel-bacterial-soft-rot",
"bay-laurel-downy-mildew-generic",
"bay-laurel-viral-leaf-curl",
"bay-laurel-wood-rot-decay",
"cocoa-bacterial-soft-rot",
"cocoa-downy-mildew-generic",
"cocoa-viral-leaf-curl",
"cocoa-wood-rot-decay",
"cotton-bacterial-soft-rot",
"cotton-downy-mildew-generic",
"cotton-viral-leaf-curl",
"cotton-wood-rot-decay",
"okra-bacterial-soft-rot",
"okra-downy-mildew-generic",
"okra-viral-leaf-curl",
"okra-wood-rot-decay",
"hibiscus-bacterial-soft-rot",
"hibiscus-downy-mildew-generic",
"hibiscus-viral-leaf-curl",
"hibiscus-wood-rot-decay",
"hollyhock-bacterial-soft-rot",
"hollyhock-downy-mildew-generic",
"hollyhock-viral-leaf-curl",
"hollyhock-wood-rot-decay",
"baobab-bacterial-soft-rot",
"baobab-downy-mildew-generic",
"baobab-viral-leaf-curl",
"baobab-wood-rot-decay",
"durian-bacterial-soft-rot",
"durian-downy-mildew-generic",
"durian-viral-leaf-curl",
"durian-wood-rot-decay",
"coconut-bacterial-soft-rot",
"coconut-downy-mildew-generic",
"coconut-viral-leaf-curl",
"coconut-wood-rot-decay",
"oil-palm-bacterial-soft-rot",
"oil-palm-downy-mildew-generic",
"oil-palm-viral-leaf-curl",
"oil-palm-wood-rot-decay",
"date-palm-bacterial-soft-rot",
"date-palm-downy-mildew-generic",
"date-palm-viral-leaf-curl",
"date-palm-wood-rot-decay",
"palm-areca-bacterial-soft-rot",
"palm-areca-downy-mildew-generic",
"palm-areca-viral-leaf-curl",
"palm-areca-wood-rot-decay",
"palm-parlor-bacterial-soft-rot",
"palm-parlor-downy-mildew-generic",
"palm-parlor-viral-leaf-curl",
"palm-parlor-wood-rot-decay",
"palm-kentia-bacterial-soft-rot",
"palm-kentia-downy-mildew-generic",
"palm-kentia-viral-leaf-curl",
"palm-kentia-wood-rot-decay",
"mango-bacterial-soft-rot",
"mango-downy-mildew-generic",
"mango-viral-leaf-curl",
"mango-wood-rot-decay",
"cashew-bacterial-soft-rot",
"cashew-downy-mildew-generic",
"cashew-viral-leaf-curl",
"cashew-wood-rot-decay",
"pistachio-bacterial-soft-rot",
"pistachio-downy-mildew-generic",
"pistachio-viral-leaf-curl",
"pistachio-wood-rot-decay",
"poison-ivy-bacterial-soft-rot",
"poison-ivy-downy-mildew-generic",
"poison-ivy-viral-leaf-curl",
"poison-ivy-wood-rot-decay",
"coffee-bacterial-soft-rot",
"coffee-downy-mildew-generic",
"coffee-viral-leaf-curl",
"coffee-wood-rot-decay",
"gardenia-bacterial-soft-rot",
"gardenia-downy-mildew-generic",
"gardenia-viral-leaf-curl",
"gardenia-wood-rot-decay",
"tea-bacterial-soft-rot",
"tea-downy-mildew-generic",
"tea-viral-leaf-curl",
"tea-wood-rot-decay",
"camellia-bacterial-soft-rot",
"camellia-downy-mildew-generic",
"camellia-viral-leaf-curl",
"camellia-wood-rot-decay",
"pine-bacterial-soft-rot",
"pine-downy-mildew-generic",
"pine-viral-leaf-curl",
"pine-wood-rot-decay",
"spruce-bacterial-soft-rot",
"spruce-downy-mildew-generic",
"spruce-viral-leaf-curl",
"spruce-wood-rot-decay",
"fir-bacterial-soft-rot",
"fir-downy-mildew-generic",
"fir-viral-leaf-curl",
"fir-wood-rot-decay",
"cedar-bacterial-soft-rot",
"cedar-downy-mildew-generic",
"cedar-viral-leaf-curl",
"cedar-wood-rot-decay",
"juniper-bacterial-soft-rot",
"juniper-downy-mildew-generic",
"juniper-viral-leaf-curl",
"juniper-wood-rot-decay",
"cypress-bacterial-soft-rot",
"cypress-downy-mildew-generic",
"cypress-viral-leaf-curl",
"cypress-wood-rot-decay",
"arborvitae-bacterial-soft-rot",
"arborvitae-downy-mildew-generic",
"arborvitae-viral-leaf-curl",
"arborvitae-wood-rot-decay",
"oak-bacterial-soft-rot",
"oak-downy-mildew-generic",
"oak-viral-leaf-curl",
"oak-wood-rot-decay",
"beech-bacterial-soft-rot",
"beech-downy-mildew-generic",
"beech-viral-leaf-curl",
"beech-wood-rot-decay",
"chestnut-bacterial-soft-rot",
"chestnut-downy-mildew-generic",
"chestnut-viral-leaf-curl",
"chestnut-wood-rot-decay",
"fiddle-leaf-fig-bacterial-soft-rot",
"fiddle-leaf-fig-downy-mildew-generic",
"fiddle-leaf-fig-viral-leaf-curl",
"fiddle-leaf-fig-wood-rot-decay",
"rubber-tree-bacterial-soft-rot",
"rubber-tree-downy-mildew-generic",
"rubber-tree-viral-leaf-curl",
"rubber-tree-wood-rot-decay",
"weeping-fig-bacterial-soft-rot",
"weeping-fig-downy-mildew-generic",
"weeping-fig-viral-leaf-curl",
"weeping-fig-wood-rot-decay",
"fig-bacterial-soft-rot",
"fig-downy-mildew-generic",
"fig-viral-leaf-curl",
"fig-wood-rot-decay",
"mulberry-bacterial-soft-rot",
"mulberry-downy-mildew-generic",
"mulberry-viral-leaf-curl",
"mulberry-wood-rot-decay",
"breadfruit-bacterial-soft-rot",
"breadfruit-downy-mildew-generic",
"breadfruit-viral-leaf-curl",
"breadfruit-wood-rot-decay",
"eucalyptus-bacterial-soft-rot",
"eucalyptus-downy-mildew-generic",
"eucalyptus-viral-leaf-curl",
"eucalyptus-wood-rot-decay",
"guava-bacterial-soft-rot",
"guava-downy-mildew-generic",
"guava-viral-leaf-curl",
"guava-wood-rot-decay",
"clove-bacterial-soft-rot",
"clove-downy-mildew-generic",
"clove-viral-leaf-curl",
"clove-wood-rot-decay",
"pineapple-bacterial-soft-rot",
"pineapple-downy-mildew-generic",
"pineapple-viral-leaf-curl",
"pineapple-wood-rot-decay",
"bromeliad-bacterial-soft-rot",
"bromeliad-downy-mildew-generic",
"bromeliad-viral-leaf-curl",
"bromeliad-wood-rot-decay",
"spanish-moss-bacterial-soft-rot",
"spanish-moss-downy-mildew-generic",
"spanish-moss-viral-leaf-curl",
"spanish-moss-wood-rot-decay",
"sweet-potato-bacterial-soft-rot",
"sweet-potato-downy-mildew-generic",
"sweet-potato-viral-leaf-curl",
"sweet-potato-wood-rot-decay",
"morning-glory-bacterial-soft-rot",
"morning-glory-downy-mildew-generic",
"morning-glory-viral-leaf-curl",
"morning-glory-wood-rot-decay",
"spinach-downy-mildew-generic",
"spinach-viral-leaf-curl",
"spinach-wood-rot-decay",
"swiss-chard-bacterial-soft-rot",
"swiss-chard-downy-mildew-generic",
"swiss-chard-viral-leaf-curl",
"swiss-chard-wood-rot-decay",
"beet-bacterial-soft-rot",
"beet-downy-mildew-generic",
"beet-viral-leaf-curl",
"beet-wood-rot-decay",
"quinoa-bacterial-soft-rot",
"quinoa-downy-mildew-generic",
"quinoa-viral-leaf-curl",
"quinoa-wood-rot-decay",
"amaranth-bacterial-soft-rot",
"amaranth-downy-mildew-generic",
"amaranth-viral-leaf-curl",
"amaranth-wood-rot-decay",
"rhubarb-bacterial-soft-rot",
"rhubarb-downy-mildew-generic",
"rhubarb-viral-leaf-curl",
"rhubarb-wood-rot-decay",
"buckwheat-bacterial-soft-rot",
"buckwheat-downy-mildew-generic",
"buckwheat-viral-leaf-curl",
"buckwheat-wood-rot-decay",
"papaya-bacterial-soft-rot",
"papaya-downy-mildew-generic",
"papaya-viral-leaf-curl",
"papaya-wood-rot-decay",
"olive-bacterial-soft-rot",
"olive-downy-mildew-generic",
"olive-viral-leaf-curl",
"olive-wood-rot-decay",
"jasmine-bacterial-soft-rot",
"jasmine-downy-mildew-generic",
"jasmine-viral-leaf-curl",
"jasmine-wood-rot-decay",
"lilac-bacterial-soft-rot",
"lilac-downy-mildew-generic",
"lilac-viral-leaf-curl",
"lilac-wood-rot-decay",
"ash-bacterial-soft-rot",
"ash-downy-mildew-generic",
"ash-viral-leaf-curl",
"ash-wood-rot-decay",
"hops-bacterial-soft-rot",
"hops-downy-mildew-generic",
"hops-viral-leaf-curl",
"hops-wood-rot-decay",
"hemp-bacterial-soft-rot",
"hemp-downy-mildew-generic",
"hemp-viral-leaf-curl",
"hemp-wood-rot-decay",
"fern-boston-bacterial-soft-rot",
"fern-boston-downy-mildew-generic",
"fern-boston-viral-leaf-curl",
"fern-boston-wood-rot-decay",
"fern-maidenhair-bacterial-soft-rot",
"fern-maidenhair-downy-mildew-generic",
"fern-maidenhair-viral-leaf-curl",
"fern-maidenhair-wood-rot-decay",
"spider-plant-bacterial-soft-rot",
"spider-plant-downy-mildew-generic",
"spider-plant-viral-leaf-curl",
"spider-plant-wood-rot-decay",
"zz-plant-bacterial-soft-rot",
"zz-plant-downy-mildew-generic",
"zz-plant-viral-leaf-curl",
"zz-plant-wood-rot-decay",
"prayer-plant-bacterial-soft-rot",
"prayer-plant-downy-mildew-generic",
"prayer-plant-viral-leaf-curl",
"prayer-plant-wood-rot-decay",
"calathea-bacterial-soft-rot",
"calathea-downy-mildew-generic",
"calathea-viral-leaf-curl",
"calathea-wood-rot-decay",
"pilea-bacterial-soft-rot",
"pilea-downy-mildew-generic",
"pilea-viral-leaf-curl",
"pilea-wood-rot-decay",
"tradescantia-bacterial-soft-rot",
"tradescantia-downy-mildew-generic",
"tradescantia-viral-leaf-curl",
"tradescantia-wood-rot-decay",
"succulent-echeveria-bacterial-soft-rot",
"succulent-echeveria-downy-mildew-generic",
"succulent-echeveria-viral-leaf-curl",
"succulent-echeveria-wood-rot-decay",
"money-tree-bacterial-soft-rot",
"money-tree-downy-mildew-generic",
"money-tree-viral-leaf-curl",
"money-tree-wood-rot-decay",
"palm-cat-bacterial-soft-rot",
"palm-cat-downy-mildew-generic",
"palm-cat-viral-leaf-curl",
"palm-cat-wood-rot-decay",
"ficus-altissima-bacterial-soft-rot",
"ficus-altissima-downy-mildew-generic",
"ficus-altissima-viral-leaf-curl",
"ficus-altissima-wood-rot-decay",
"string-of-pearls-bacterial-soft-rot",
"string-of-pearls-downy-mildew-generic",
"string-of-pearls-viral-leaf-curl",
"string-of-pearls-wood-rot-decay",
"burros-tail-bacterial-soft-rot",
"burros-tail-downy-mildew-generic",
"burros-tail-viral-leaf-curl",
"burros-tail-wood-rot-decay",
"snake-plant-masoniana-bacterial-soft-rot",
"snake-plant-masoniana-downy-mildew-generic",
"snake-plant-masoniana-viral-leaf-curl",
"snake-plant-masoniana-wood-rot-decay",
"passion-fruit-bacterial-soft-rot",
"passion-fruit-downy-mildew-generic",
"passion-fruit-viral-leaf-curl",
"passion-fruit-wood-rot-decay",
"kiwi-bacterial-soft-rot",
"kiwi-downy-mildew-generic",
"kiwi-viral-leaf-curl",
"kiwi-wood-rot-decay",
"lychee-bacterial-soft-rot",
"lychee-downy-mildew-generic",
"lychee-viral-leaf-curl",
"lychee-wood-rot-decay",
"rambutan-bacterial-soft-rot",
"rambutan-downy-mildew-generic",
"rambutan-viral-leaf-curl",
"rambutan-wood-rot-decay",
"jackfruit-bacterial-soft-rot",
"jackfruit-downy-mildew-generic",
"jackfruit-viral-leaf-curl",
"jackfruit-wood-rot-decay",
"dragon-fruit-bacterial-soft-rot",
"dragon-fruit-downy-mildew-generic",
"dragon-fruit-viral-leaf-curl",
"dragon-fruit-wood-rot-decay",
"pomegranate-bacterial-soft-rot",
"pomegranate-downy-mildew-generic",
"pomegranate-viral-leaf-curl",
"pomegranate-wood-rot-decay",
"persimmon-bacterial-soft-rot",
"persimmon-downy-mildew-generic",
"persimmon-viral-leaf-curl",
"persimmon-wood-rot-decay",
"tulip-bacterial-soft-rot",
"tulip-downy-mildew-generic",
"tulip-viral-leaf-curl",
"tulip-wood-rot-decay",
"daffodil-bacterial-soft-rot",
"daffodil-downy-mildew-generic",
"daffodil-viral-leaf-curl",
"daffodil-wood-rot-decay",
"iris-bacterial-soft-rot",
"iris-downy-mildew-generic",
"iris-viral-leaf-curl",
"iris-wood-rot-decay",
"lily-bacterial-soft-rot",
"lily-downy-mildew-generic",
"lily-viral-leaf-curl",
"lily-wood-rot-decay",
"peony-bacterial-soft-rot",
"peony-downy-mildew-generic",
"peony-viral-leaf-curl",
"peony-wood-rot-decay",
"hydrangea-bacterial-soft-rot",
"hydrangea-downy-mildew-generic",
"hydrangea-viral-leaf-curl",
"hydrangea-wood-rot-decay",
"rhododendron-bacterial-soft-rot",
"rhododendron-downy-mildew-generic",
"rhododendron-viral-leaf-curl",
"rhododendron-wood-rot-decay",
"azalea-bacterial-soft-rot",
"azalea-downy-mildew-generic",
"azalea-viral-leaf-curl",
"azalea-wood-rot-decay",
"magnolia-bacterial-soft-rot",
"magnolia-downy-mildew-generic",
"magnolia-viral-leaf-curl",
"magnolia-wood-rot-decay",
"dogwood-bacterial-soft-rot",
"dogwood-downy-mildew-generic",
"dogwood-viral-leaf-curl",
"dogwood-wood-rot-decay",
"maple-bacterial-soft-rot",
"maple-downy-mildew-generic",
"maple-viral-leaf-curl",
"maple-wood-rot-decay",
"birch-bacterial-soft-rot",
"birch-downy-mildew-generic",
"birch-viral-leaf-curl",
"birch-wood-rot-decay",
"elm-bacterial-soft-rot",
"elm-downy-mildew-generic",
"elm-viral-leaf-curl",
"elm-wood-rot-decay",
"willow-bacterial-soft-rot",
"willow-downy-mildew-generic",
"willow-viral-leaf-curl",
"willow-wood-rot-decay",
"poplar-bacterial-soft-rot",
"poplar-downy-mildew-generic",
"poplar-viral-leaf-curl",
"poplar-wood-rot-decay",
"sycamore-bacterial-soft-rot",
"sycamore-downy-mildew-generic",
"sycamore-viral-leaf-curl",
"sycamore-wood-rot-decay",
"hickory-bacterial-soft-rot",
"hickory-downy-mildew-generic",
"hickory-viral-leaf-curl",
"hickory-wood-rot-decay",
"pecan-bacterial-soft-rot",
"pecan-downy-mildew-generic",
"pecan-viral-leaf-curl",
"pecan-wood-rot-decay",
"walnut-bacterial-soft-rot",
"walnut-downy-mildew-generic",
"walnut-viral-leaf-curl",
"walnut-wood-rot-decay",
"fern-staghorn-root-rot-pythiumphytophthora",
"fern-staghorn-damping-off",
"fern-staghorn-gray-mold-botrytis-blight",
"fern-staghorn-mosaic-virus",
"fern-staghorn-wilt-fusarium-or-verticillium",
"fern-staghorn-root-knot-nematode",
"fern-staghorn-canker-stembranch",
"fern-staghorn-bacterial-soft-rot",
"fern-staghorn-downy-mildew-generic",
"fern-staghorn-viral-leaf-curl",
"fern-staghorn-wood-rot-decay",
"fern-birds-nest-root-rot-pythiumphytophthora",
"fern-birds-nest-damping-off",
"fern-birds-nest-gray-mold-botrytis-blight",
"fern-birds-nest-mosaic-virus",
"fern-birds-nest-wilt-fusarium-or-verticillium",
"fern-birds-nest-root-knot-nematode",
"fern-birds-nest-canker-stembranch",
"fern-birds-nest-bacterial-soft-rot",
"fern-birds-nest-downy-mildew-generic",
"fern-birds-nest-viral-leaf-curl",
"fern-birds-nest-wood-rot-decay",
"philodendron-brasil-root-rot-aroidsoverwatering",
"philodendron-brasil-root-rot-pythiumphytophthora",
"philodendron-brasil-damping-off",
"philodendron-brasil-gray-mold-botrytis-blight",
"philodendron-brasil-mosaic-virus",
"philodendron-brasil-wilt-fusarium-or-verticillium",
"philodendron-brasil-root-knot-nematode",
"philodendron-brasil-canker-stembranch",
"philodendron-brasil-bacterial-soft-rot",
"philodendron-brasil-downy-mildew-generic",
"philodendron-brasil-viral-leaf-curl",
"philodendron-brasil-wood-rot-decay",
"philodendron-monstera-root-rot-aroidsoverwatering",
"philodendron-monstera-root-rot-pythiumphytophthora",
"philodendron-monstera-damping-off",
"philodendron-monstera-gray-mold-botrytis-blight",
"philodendron-monstera-mosaic-virus",
"philodendron-monstera-wilt-fusarium-or-verticillium",
"philodendron-monstera-root-knot-nematode",
"philodendron-monstera-canker-stembranch",
"philodendron-monstera-bacterial-soft-rot",
"philodendron-monstera-downy-mildew-generic"
],
"totalFound": 650
}

View File

@@ -293,5 +293,25 @@
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf/page1-500px-Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf.jpg",
"source": "commons",
"quality": "good"
},
"lettuce-tip-burn": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Medical_Heritage_Library_%28IA_63841040R.nlm.nih.gov%29.pdf/page1-500px-Medical_Heritage_Library_%28IA_63841040R.nlm.nih.gov%29.pdf.jpg",
"source": "commons",
"quality": "good"
},
"cabbage-downy-mildew": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Chinakohl_3_Falscher_Mehltau-Brand.jpg/960px-Chinakohl_3_Falscher_Mehltau-Brand.jpg",
"source": "commons",
"quality": "good"
},
"cabbage-fusarium-yellows": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Index_of_organisms_and_non-parasitic_diseases_in_Plant_disease_reporter%2C_supplements_XXXI-XXXVII%2C_1924_%28IA_indexoforganisms38vanm%29.pdf/page1-960px-Index_of_organisms_and_non-parasitic_diseases_in_Plant_disease_reporter%2C_supplements_XXXI-XXXVII%2C_1924_%28IA_indexoforganisms38vanm%29.pdf.jpg",
"source": "commons",
"quality": "good"
},
"sunflower-downy-mildew": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Plasmopara_halstedii_R.H._07.jpg/960px-Plasmopara_halstedii_R.H._07.jpg",
"source": "commons",
"quality": "good"
}
}

View File

@@ -0,0 +1,136 @@
# Disease Images — Human Review Needed
Generated: 2026-06-06T14:20:43.602Z
## Summary
- Total diseases: 93
- Good images (Wiki/Commons): 63
- Fallback images (Brave): 30
- Still missing: 0
## ⚠️ Fallback Images (Brave) — Review Required
These 30 diseases have images from Brave Image Search.
Quality/relevance may be lower than Wikipedia/Commons sources.
- **Cercospora Leaf Spot** (Cercospora iconia) on *basil*
![](https://imgs.search.brave.com/qEe1QooFmBPBMvor3EDzfZP5vVYGlwOx7EytFqTviOQ/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9pbWFn/ZS5zbGlkZXNoYXJl/Y2RuLmNvbS9kaXNl/YXNlc29mYmFzaWxh/bmRtaW50LTE4MDYw/MjE2MzI1Ni83NS9E/aXNlYXNlcy1vZi1i/YXNpbC1hbmQtbWlu/dC0xMi0yMDQ4Lmpw/Zw)
URL: https://imgs.search.brave.com/qEe1QooFmBPBMvor3EDzfZP5vVYGlwOx7EytFqTviOQ/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9pbWFn/ZS5zbGlkZXNoYXJl/Y2RuLmNvbS9kaXNl/YXNlc29mYmFzaWxh/bmRtaW50LTE4MDYw/MjE2MzI1Ni83NS9E/aXNlYXNlcy1vZi1i/YXNpbC1hbmQtbWlu/dC0xMi0yMDQ4Lmpw/Zw
- **Root Rot (Pythium)** (Pythium spp.) on *basil*
![](https://imgs.search.brave.com/8Qv7FgXxZpOhf9FM12OLnGoyNFHqPJfn3HPWzcgiK-I/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly9iLnRo/dW1icy5yZWRkaXRt/ZWRpYS5jb20vWHU1/dnY4Q0Z1ZDZxZXZJ/YTVqTmdfNWtRbkZq/VXA1eFRMV19YYW5Y/U2NLVS5qcGc)
URL: https://imgs.search.brave.com/8Qv7FgXxZpOhf9FM12OLnGoyNFHqPJfn3HPWzcgiK-I/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly9iLnRo/dW1icy5yZWRkaXRt/ZWRpYS5jb20vWHU1/dnY4Q0Z1ZDZxZXZJ/YTVqTmdfNWtRbkZq/VXA1eFRMV19YYW5Y/U2NLVS5qcGc
- **Black Spot** (Diplocarpon rosae) on *rose*
![](https://imgs.search.brave.com/FhrhtzbypH95L6uCYLY8YVHAh9EohvnKUiARre4JB9g/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNS9Sb3NlLUZv/bGlhZ2Utd2l0aC1C/bGFjay1TcG90Lmpw/Zw)
URL: https://imgs.search.brave.com/FhrhtzbypH95L6uCYLY8YVHAh9EohvnKUiARre4JB9g/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNS9Sb3NlLUZv/bGlhZ2Utd2l0aC1C/bGFjay1TcG90Lmpw/Zw
- **Leaf Spot (Cercospora)** (Cercospora spp.) on *monstera*
![](https://imgs.search.brave.com/HxJxD9jMUqCj6btc1tedJw-fWV6HTCyiE2lEXkJ0_Pk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udXN1LmVk/dS9wbGFudGhlYWx0/aC9pcG0vaW1hZ2Vz/L2FncmljdWx0dXJh/bC92ZWdldGFibGVz/L0NlcmNvc3BvcmEt/bGVhZi1zcG90LXNw/aW5hY2guanBn)
URL: https://imgs.search.brave.com/HxJxD9jMUqCj6btc1tedJw-fWV6HTCyiE2lEXkJ0_Pk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udXN1LmVk/dS9wbGFudGhlYWx0/aC9pcG0vaW1hZ2Vz/L2FncmljdWx0dXJh/bC92ZWdldGFibGVz/L0NlcmNvc3BvcmEt/bGVhZi1zcG90LXNw/aW5hY2guanBn
- **Cold Damage / Freeze Injury** (Abiotic temperature injury) on *monstera*
![](https://imgs.search.brave.com/ershnjIJ0rMnhFoGVHhTYhwwjSy4UVehWwlJ9ZKF0FU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wcmV2/aWV3LnJlZGQuaXQv/aXMtdGhlcmUtaG9w/ZS1mb3ItbXktbW9u/c3RlcmEtdGhhdC1n/b3QtZnJvc3QtZGFt/YWdlLWlmLWFsbC12/MC1wcmswejc1cm1w/ZDYxLmpwZz93aWR0/aD02NDAmY3JvcD1z/bWFydCZhdXRvPXdl/YnAmcz1mYTVmOTMz/MTlkOWFiOTY3ZTlm/MzdkY2VhNDY5YjQ1/ODQ4NTNiYjMw)
URL: https://imgs.search.brave.com/ershnjIJ0rMnhFoGVHhTYhwwjSy4UVehWwlJ9ZKF0FU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wcmV2/aWV3LnJlZGQuaXQv/aXMtdGhlcmUtaG9w/ZS1mb3ItbXktbW9u/c3RlcmEtdGhhdC1n/b3QtZnJvc3QtZGFt/YWdlLWlmLWFsbC12/MC1wcmswejc1cm1w/ZDYxLmpwZz93aWR0/aD02NDAmY3JvcD1z/bWFydCZhdXRvPXdl/YnAmcz1mYTVmOTMz/MTlkOWFiOTY3ZTlm/MzdkY2VhNDY5YjQ1/ODQ4NTNiYjMw
- **Spider Mite Infestation** (Tetranychus urticae) on *monstera*
![](https://imgs.search.brave.com/TR9wjFRWkyus-jwsWOsygIVJbT44ddPEtvBxrYuf-RE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/c2hvcGlmeS5jb20v/cy9maWxlcy8xLzEy/ODkvMjA0MS9maWxl/cy9lYXJseS1zaWdu/cy1vZi1zcGlkZXIt/bWl0ZXMtMTRfMjA0/OHgyMDQ4LmpwZz92/PTE3MjY2NzAzNTc)
URL: https://imgs.search.brave.com/TR9wjFRWkyus-jwsWOsygIVJbT44ddPEtvBxrYuf-RE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/c2hvcGlmeS5jb20v/cy9maWxlcy8xLzEy/ODkvMjA0MS9maWxl/cy9lYXJseS1zaWdu/cy1vZi1zcGlkZXIt/bWl0ZXMtMTRfMjA0/OHgyMDQ4LmpwZz92/PTE3MjY2NzAzNTc
- **Mealybug Infestation** (Pseudococcus longispinus) on *pothos*
![](https://imgs.search.brave.com/9ObqyNw4LWLVwrKJNnXuXPYnk9ghE5IKK-IZRTDlErw/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5mb3JpbmRvb3Iu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIxLzA1L01lYWx5/YnVncy1vbi1wb3Ro/b3MuanBn)
URL: https://imgs.search.brave.com/9ObqyNw4LWLVwrKJNnXuXPYnk9ghE5IKK-IZRTDlErw/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5mb3JpbmRvb3Iu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIxLzA1L01lYWx5/YnVncy1vbi1wb3Ro/b3MuanBn
- **Bacterial Soft Rot** (Erwinia carotovora) on *pothos*
![](https://imgs.search.brave.com/zeGhUbP2_Dt05TgOWAP5DNItLTB9EaI4bWTcKecE-5U/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24uc2RzdGF0/ZS5lZHUvc2l0ZXMv/ZGVmYXVsdC9maWxl/cy9pbmxpbmUtaW1h/Z2VzL1ctMDE3MDkt/MDEtQmFjdGVyaWFs/LVNvZnQtUm90LVN5/bXB0b21zLVN0ZW0u/anBn)
URL: https://imgs.search.brave.com/zeGhUbP2_Dt05TgOWAP5DNItLTB9EaI4bWTcKecE-5U/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24uc2RzdGF0/ZS5lZHUvc2l0ZXMv/ZGVmYXVsdC9maWxl/cy9pbmxpbmUtaW1h/Z2VzL1ctMDE3MDkt/MDEtQmFjdGVyaWFs/LVNvZnQtUm90LVN5/bXB0b21zLVN0ZW0u/anBn
- **Bacterial Leaf Spot** (Xanthomonas campestris pv. dieffenbachiae) on *peace-lily*
![](https://imgs.search.brave.com/cnyXR2l1-H5EDwRDsAIxxf1aXwjzhnB2lcBzwWzLRu8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wbGFu/dGFtZXJpY2EuY29t/L3dwLWNvbnRlbnQv/dXBsb2Fkcy8yMDI0/LzA0L0xlYWYtU3Bv/dHMtb24tUGVhY2Ut/TGlseS1QbGFudC1B/bWVyaWNhLmpwZw)
URL: https://imgs.search.brave.com/cnyXR2l1-H5EDwRDsAIxxf1aXwjzhnB2lcBzwWzLRu8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wbGFu/dGFtZXJpY2EuY29t/L3dwLWNvbnRlbnQv/dXBsb2Fkcy8yMDI0/LzA0L0xlYWYtU3Bv/dHMtb24tUGVhY2Ut/TGlseS1QbGFudC1B/bWVyaWNhLmpwZw
- **Botrytis Blight (Gray Mold)** (Botrytis cinerea) on *peace-lily*
![](https://imgs.search.brave.com/Z3QnqltBrd5UYNFLiZp3t0oBQSJ2loklsrVSuIekQKg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/bW9zLmNtcy5mdXR1/cmVjZG4ubmV0L2h4/bXFBNWZORXk4NnJ0/YlRqUXlmdWYuanBn)
URL: https://imgs.search.brave.com/Z3QnqltBrd5UYNFLiZp3t0oBQSJ2loklsrVSuIekQKg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/bW9zLmNtcy5mdXR1/cmVjZG4ubmV0L2h4/bXFBNWZORXk4NnJ0/YlRqUXlmdWYuanBn
- **Fungal Leaf Spot** (Alternaria / Cercospora spp.) on *orchid*
![](https://imgs.search.brave.com/nwqmpPs_7Fgo5qbYAWXaGJIY4oNibmfZ8QE7RasMWUU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udW1kLmVk/dS9zaXRlcy9leHRl/bnNpb24udW1kLmVk/dS9maWxlcy9zdHls/ZXMvb3B0aW1pemVk/L3B1YmxpYy8yMDIx/LTAzL2hnaWNfaG91/c2VwbGFudF9mdW5n/YWxfbGVhZl9zcG90/X29yY2hpZC1IR0lD/LTEyNDItMDMxLXNs/aWRlLmpwZz9pdG9r/PVg2RTVQUGFk)
URL: https://imgs.search.brave.com/nwqmpPs_7Fgo5qbYAWXaGJIY4oNibmfZ8QE7RasMWUU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udW1kLmVk/dS9zaXRlcy9leHRl/bnNpb24udW1kLmVk/dS9maWxlcy9zdHls/ZXMvb3B0aW1pemVk/L3B1YmxpYy8yMDIx/LTAzL2hnaWNfaG91/c2VwbGFudF9mdW5n/YWxfbGVhZl9zcG90/X29yY2hpZC1IR0lD/LTEyNDItMDMxLXNs/aWRlLmpwZz9pdG9r/PVg2RTVQUGFk
- **Mealybug Infestation** (Pseudococcus longispinus) on *succulent*
![](https://imgs.search.brave.com/EhBP0Sxi3YMkWaLiAi9tyn-8Xle7Gl-KgEk8I9UNK2E/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/cG90YW5kYmxvb20u/Y29tL2Nkbi9zaG9w/L2FydGljbGVzL3Vu/bmFtZWRfOC5wbmc_/dj0xNjk1OTgyMzc3/JndpZHRoPTQ4MA)
URL: https://imgs.search.brave.com/EhBP0Sxi3YMkWaLiAi9tyn-8Xle7Gl-KgEk8I9UNK2E/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/cG90YW5kYmxvb20u/Y29tL2Nkbi9zaG9w/L2FydGljbGVzL3Vu/bmFtZWRfOC5wbmc_/dj0xNjk1OTgyMzc3/JndpZHRoPTQ4MA
- **Gray Mold (Botrytis)** (Botrytis cinerea) on *strawberry*
![](https://imgs.search.brave.com/xCkZV5hmL757LmnOpWFE0GJrqRqWlNC1H2z4TqS-v_8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/MC8wNi9Cb3RyeXRp/cy1HcmF5LU1vbGQt/b24tU3RyYXdiZXJy/eS1QbGFudHMuanBn)
URL: https://imgs.search.brave.com/xCkZV5hmL757LmnOpWFE0GJrqRqWlNC1H2z4TqS-v_8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/MC8wNi9Cb3RyeXRp/cy1HcmF5LU1vbGQt/b24tU3RyYXdiZXJy/eS1QbGFudHMuanBn
- **Leaf Scorch (Phytophthora)** (Phytophthora fragariae) on *strawberry*
![](https://imgs.search.brave.com/c5oBR4uLCQ_0ivGFHwPTQRPb7BVtyWk7fum-2U0UJSk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/YWNlcy5lZHUvd3At/Y29udGVudC91cGxv/YWRzLzIwMjUvMDgv/cGh5dG9waHRob3Jh/LXN0cmF3YmVycnkt/MS0zMDB4MzAwLmpw/Zw)
URL: https://imgs.search.brave.com/c5oBR4uLCQ_0ivGFHwPTQRPb7BVtyWk7fum-2U0UJSk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/YWNlcy5lZHUvd3At/Y29udGVudC91cGxv/YWRzLzIwMjUvMDgv/cGh5dG9waHRob3Jh/LXN0cmF3YmVycnkt/MS0zMDB4MzAwLmpw/Zw
- **Gray Mold (Botrytis)** (Botrytis cinerea) on *lavender*
![](https://imgs.search.brave.com/H2WYULyyPBsX20C-QDv3bL51oD2HbzLp8dxiwFGnrRc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/dHJlZWhvdXNlLmNv/L2pha2llLW9iamF3/eS1zemFyYS1wbGVz/bi5qcGc)
URL: https://imgs.search.brave.com/H2WYULyyPBsX20C-QDv3bL51oD2HbzLp8dxiwFGnrRc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/dHJlZWhvdXNlLmNv/L2pha2llLW9iamF3/eS1zemFyYS1wbGVz/bi5qcGc
- **Root Rot (Phytophthora)** (Phytophthora spp.) on *lavender*
![](https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n)
URL: https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n
- **Fungal Leaf Spot** (Cercospora / Alternaria spp.) on *fiddle-leaf-fig*
![](https://imgs.search.brave.com/0w67jSvytmrzD0aW7Pnj66RMkHm0t-1XB9XYMelmIFg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9iMjk0/NTA0MS5zbXVzaGNk/bi5jb20vMjk0NTA0/MS93cC1jb250ZW50/L3VwbG9hZHMvMjAx/OS8wMS9JTUdfOTc2/Mi0xLmpwZz9sb3Nz/eT0xJnN0cmlwPTEm/d2VicD0x)
URL: https://imgs.search.brave.com/0w67jSvytmrzD0aW7Pnj66RMkHm0t-1XB9XYMelmIFg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9iMjk0/NTA0MS5zbXVzaGNk/bi5jb20vMjk0NTA0/MS93cC1jb250ZW50/L3VwbG9hZHMvMjAx/OS8wMS9JTUdfOTc2/Mi0xLmpwZz9sb3Nz/eT0xJnN0cmlwPTEm/d2VicD0x
- **Spider Mite Infestation** (Tetranychus urticae) on *fiddle-leaf-fig*
![](https://imgs.search.brave.com/KsAcvKooEPszWNcjEkd60Yh0RwH9mkuAJYDyBk5bAYk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZG9zc2llcmJsb2cu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIwLzAyLzFoZWFk/ZXItdHJlYXQtc3Bp/ZGVyLW1pdGVzLWZp/ZGRsZS1pZy01MTJ4/NzY4LmpwZw)
URL: https://imgs.search.brave.com/KsAcvKooEPszWNcjEkd60Yh0RwH9mkuAJYDyBk5bAYk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZG9zc2llcmJsb2cu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIwLzAyLzFoZWFk/ZXItdHJlYXQtc3Bp/ZGVyLW1pdGVzLWZp/ZGRsZS1pZy01MTJ4/NzY4LmpwZw
- **Mealybug Infestation** (Pseudococcus longispinus) on *aloe-vera*
![](https://imgs.search.brave.com/L-rTzPOjDU_1iPtea1Jjj8Lfxfn9LG9UdkOJLG1rmlM/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4w/LnRoZWRhaWx5ZWNv/LmNvbS9lbi9wb3N0/cy85LzEvNi9sZWFm/X3Nwb3RfZGlzZWFz/ZV82MTlfMF82MDAu/anBn)
URL: https://imgs.search.brave.com/L-rTzPOjDU_1iPtea1Jjj8Lfxfn9LG9UdkOJLG1rmlM/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4w/LnRoZWRhaWx5ZWNv/LmNvbS9lbi9wb3N0/cy85LzEvNi9sZWFm/X3Nwb3RfZGlzZWFz/ZV82MTlfMF82MDAu/anBn
- **Tobacco Mosaic Virus** (Tobacco mosaic virus (TMV)) on *jasmine*
![](https://imgs.search.brave.com/m2lePdBbR7N-OUW_l4saDGEmJ-b6TKpv719fJAzP44A/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNy9KYXNtaW5l/LURpc2Vhc2VzLUZl/YXR1cmUuanBn)
URL: https://imgs.search.brave.com/m2lePdBbR7N-OUW_l4saDGEmJ-b6TKpv719fJAzP44A/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNy9KYXNtaW5l/LURpc2Vhc2VzLUZl/YXR1cmUuanBn
- **Blossom End Rot** (Calcium deficiency disorder) on *chili*
![](https://imgs.search.brave.com/G27ys6xN2xOxuIim46kDPLGDqsMGJkQCTYp4Qxnx3AE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/bWlzc291cmlib3Rh/bmljYWxnYXJkZW4u/b3JnL1BvcnRhbHMv/MC9HYXJkZW5pbmcv/R2FyZGVuaW5nJTIw/SGVscC9pbWFnZXMv/UGVzdHMvQmxvc3Nv/bV9FbmRfUm90X29m/X1RvbWF0bzIwNTku/anBn)
URL: https://imgs.search.brave.com/G27ys6xN2xOxuIim46kDPLGDqsMGJkQCTYp4Qxnx3AE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/bWlzc291cmlib3Rh/bmljYWxnYXJkZW4u/b3JnL1BvcnRhbHMv/MC9HYXJkZW5pbmcv/R2FyZGVuaW5nJTIw/SGVscC9pbWFnZXMv/UGVzdHMvQmxvc3Nv/bV9FbmRfUm90X29m/X1RvbWF0bzIwNTku/anBn
- **Phomopsis Blight** (Phomopsis capsici) on *chili*
![](https://imgs.search.brave.com/Bw2HKsqR-VUW2dIDs23YmpJOT2aVmbzbQljpQc-m6FY/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/Z2FyZGVuZXJzLmNv/bS9jZG4vc2hvcC9h/cnRpY2xlcy83Mjk2/LVBob21vcHNpcy1C/bGlnaHQtZWdncGxh/bnQuanBnP3Y9MTc1/NDkzODQ1NyZ3aWR0/aD0zMjA)
URL: https://imgs.search.brave.com/Bw2HKsqR-VUW2dIDs23YmpJOT2aVmbzbQljpQc-m6FY/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/Z2FyZGVuZXJzLmNv/bS9jZG4vc2hvcC9h/cnRpY2xlcy83Mjk2/LVBob21vcHNpcy1C/bGlnaHQtZWdncGxh/bnQuanBnP3Y9MTc1/NDkzODQ1NyZ3aWR0/aD0zMjA
- **Blossom End Rot** (Calcium deficiency disorder) on *eggplant*
![](https://imgs.search.brave.com/M5VmsJWcGGV3jd1JRaELFW-dUZY4QYEpeeBOY6s1ZpI/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93cGNk/bi53ZWIud3N1LmVk/dS9leHRlbnNpb24v/dXBsb2Fkcy9zaXRl/cy8zMS9wZXBwZXIt/Ymxvc3NvbS1lbmQt/cm90LTFMLTM5Nngy/OTAuanBn)
URL: https://imgs.search.brave.com/M5VmsJWcGGV3jd1JRaELFW-dUZY4QYEpeeBOY6s1ZpI/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93cGNk/bi53ZWIud3N1LmVk/dS9leHRlbnNpb24v/dXBsb2Fkcy9zaXRl/cy8zMS9wZXBwZXIt/Ymxvc3NvbS1lbmQt/cm90LTFMLTM5Nngy/OTAuanBn
- **Verruculosis** (Phoma macdonaldii) on *eggplant*
![](https://imgs.search.brave.com/jJoYdSyzMN7ZkgFRr1RDeFMSBitksjk9LTIAsCI1jHg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly90aHVt/YnMuZHJlYW1zdGlt/ZS5jb20vYi9sZWFm/LWVnZ3BsYW50LWFs/YmluaXNtLXN5bXB0/b20tbGVhZi1lZ2dw/bGFudC1hbGJpbmlz/bS1zeW1wdG9tLTQ1/MzYzOTQ3My5qcGc)
URL: https://imgs.search.brave.com/jJoYdSyzMN7ZkgFRr1RDeFMSBitksjk9LTIAsCI1jHg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly90aHVt/YnMuZHJlYW1zdGlt/ZS5jb20vYi9sZWFm/LWVnZ3BsYW50LWFs/YmluaXNtLXN5bXB0/b20tbGVhZi1lZ2dw/bGFudC1hbGJpbmlz/bS1zeW1wdG9tLTQ1/MzYzOTQ3My5qcGc
- **Fern Rust** (Uromyces spp.) on *fern*
![](https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc)
URL: https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc
- **Root Rot (Crown Rot)** (Phytophthora / Pythium spp.) on *fern*
![](https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n)
URL: https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n
- **Powdery Mildew** (Erysiphe spp.) on *daisy*
![](https://imgs.search.brave.com/AwTYk5ex0GDr38LBt-uk9Pi7yOic_sTSeiTVYlEtNW4/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly93d3cu/dW1hc3MuZWR1L2Fn/cmljdWx0dXJlLWZv/b2QtZW52aXJvbm1l/bnQvc2l0ZXMvZGVm/YXVsdC9maWxlcy9z/dHlsZXMvMTUweDE1/MC9wdWJsaWMvZmFj/dC1zaGVldHMvaW1h/Z2VzL3Bvd2Rlcnlf/bWlsZF8wMy5qcGc_/aXRvaz1kZTZVMFZ0/UA)
URL: https://imgs.search.brave.com/AwTYk5ex0GDr38LBt-uk9Pi7yOic_sTSeiTVYlEtNW4/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly93d3cu/dW1hc3MuZWR1L2Fn/cmljdWx0dXJlLWZv/b2QtZW52aXJvbm1l/bnQvc2l0ZXMvZGVm/YXVsdC9maWxlcy9z/dHlsZXMvMTUweDE1/MC9wdWJsaWMvZmFj/dC1zaGVldHMvaW1h/Z2VzL3Bvd2Rlcnlf/bWlsZF8wMy5qcGc_/aXRvaz1kZTZVMFZ0/UA
- **Rust** (Puccinia spp.) on *daisy*
![](https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc)
URL: https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc
- **Downy Mildew** (Peronospora spp.) on *daisy*
![](https://imgs.search.brave.com/h65q4ea2_EVIu5_NsJVPwUOVdrdVfhZGcr42TPFFEF0/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjQvMDkvZG93/bnktbWlsZGV3LXZl/Z2V0YWJsZS1nYXJk/ZW4uanBn)
URL: https://imgs.search.brave.com/h65q4ea2_EVIu5_NsJVPwUOVdrdVfhZGcr42TPFFEF0/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjQvMDkvZG93/bnktbWlsZGV3LXZl/Z2V0YWJsZS1nYXJk/ZW4uanBn
- **Stem Rot (Fusarium)** (Fusarium spp.) on *cactus*
![](https://imgs.search.brave.com/PF-Eqq7LSywJp8gzOgPppbHMfsXG4Ruj9zLZKkmxYRU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjMvMTIvRnVu/Z3VzLWRpc2Vhc2Uu/anBn)
URL: https://imgs.search.brave.com/PF-Eqq7LSywJp8gzOgPppbHMfsXG4Ruj9zLZKkmxYRU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjMvMTIvRnVu/Z3VzLWRpc2Vhc2Uu/anBn

View File

@@ -0,0 +1,10 @@
# Plant Images - Still Missing
Generated: 2026-06-06T17:08:24.166Z
## Missing (4)
- Calabash (Bottle Gourd) (calabash)
- ZZ Plant (zz-plant)
- Stromanthe Triostar (stromanthe)
- Shanghai Bok Choy (bok-choy-shanghai)

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Inspect and convert a .keras plant disease model to TF.js GraphModel format.
Uses tensorflowjs_converter CLI to avoid Keras version deserialization issues.
Usage:
pip3 install tensorflowjs # also pulls tensorflow as dependency
python3 scripts/convert-keras-to-tfjs.py
"""
import json
import os
import shutil
import subprocess
import sys
MODEL_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"public",
"models",
"plant-disease-classifier",
"best_mnv2_pv_original.keras",
)
OUTPUT_DIR = os.path.join(
os.path.dirname(MODEL_PATH),
"tfjs_model",
)
def inspect_keras_metadata():
"""Read .keras archive metadata without loading the model."""
print("=" * 60)
print("MODEL INSPECTION (metadata only)")
print("=" * 60)
try:
import zipfile
except ImportError:
print("ERROR: zipfile not available")
sys.exit(1)
if not os.path.exists(MODEL_PATH):
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
print(f"\nModel file: {MODEL_PATH}")
print(
f"File size: {os.path.getsize(MODEL_PATH):,} bytes ({os.path.getsize(MODEL_PATH) / 1024 / 1024:.1f} MB)"
)
# .keras files are ZIP archives
with zipfile.ZipFile(MODEL_PATH) as zf:
names = zf.namelist()
print(f"\nArchive contents ({len(names)} entries):")
for name in names:
info = zf.getinfo(name)
print(f" {name:<40s} {info.file_size:>10,} bytes")
# Read config.json for model architecture info
config_path = None
for name in names:
if name.endswith("config.json"):
config_path = name
break
if config_path:
print(f"\nReading {config_path}...")
with zf.open(config_path) as f:
config = json.load(f)
# Extract key info
model_type = config.get("class_name", "unknown")
print(f"Model class: {model_type}")
# Try to find output layer info
if "config" in config:
inner_config = config["config"]
# Look for output shape in config
if "output_shape" in inner_config:
print(f"Output shape: {inner_config['output_shape']}")
# Look through layers for the final dense layer
if "layers" in inner_config:
layers = inner_config["layers"]
print(f"\nLayers ({len(layers)} total):")
for layer in layers:
layer_name = layer.get("config", {}).get("name", "?")
layer_class = layer.get("class_name", "?")
layer_module = layer.get("module", "?")
# Extract units/activation for dense layers
layer_config = layer.get("config", {})
units = layer_config.get("units")
activation = layer_config.get("activation")
detail = ""
if units:
detail = f" units={units}"
if activation:
detail += f" activation={activation}"
print(f" {layer_name:<30s} {layer_class:<20s}{detail}")
# Find last dense layer for class count
for layer in reversed(layers):
if layer.get("class_name") == "Dense":
units = layer.get("config", {}).get("units")
activation = layer.get("config", {}).get("activation")
print("\nClassification head:")
print(f" Units (classes): {units}")
print(f" Activation: {activation}")
print(
f" Layer name: {layer.get('config', {}).get('name', '?')}"
)
break
# Check compile config
if "compile_config" in config:
compile_cfg = config["compile_config"]
optimizer = compile_cfg.get("optimizer", {})
if isinstance(optimizer, dict):
opt_name = optimizer.get("class_name", "?")
lr = optimizer.get("config", {}).get("learning_rate")
print("\nTraining config:")
print(f" Optimizer: {opt_name}")
if lr:
print(f" Learning rate: {lr}")
loss = compile_cfg.get("loss", "?")
metrics = compile_cfg.get("metrics", [])
print(f" Loss: {loss}")
print(f" Metrics: {metrics}")
# Check input shape
if "build_config" in config:
build_cfg = config["build_config"]
if "input_shape" in build_cfg:
print(f"\nInput shape: {build_cfg['input_shape']}")
def convert_to_tfjs():
"""Convert using tensorflowjs_converter CLI."""
print("\n" + "=" * 60)
print("CONVERTING TO TF.JS GRAPH MODEL")
print("=" * 60)
# Check tensorflowjs_converter CLI is available
converter = shutil.which("tensorflowjs_converter")
if not converter:
print("ERROR: tensorflowjs_converter not found in PATH.")
print(" pip3 install tensorflowjs")
sys.exit(1)
# Clean output dir
if os.path.exists(OUTPUT_DIR):
print(f"Removing existing output dir: {OUTPUT_DIR}")
shutil.rmtree(OUTPUT_DIR)
os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"\nConverting {MODEL_PATH} -> {OUTPUT_DIR}/")
print("(this may take a minute...)")
# Use the venv's python to run the converter (avoids import issues)
python_exe = sys.executable # the python running this script
result = subprocess.run(
[
python_exe,
"-m",
"tensorflowjs.converters.converter",
"--input_format=keras",
"--output_format=tfjs_graph_model",
MODEL_PATH,
OUTPUT_DIR,
],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
print("\nERROR: Conversion failed!")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
sys.exit(1)
if result.stdout:
print(result.stdout)
if result.stderr:
# Some warnings are normal
print(f"Converter output: {result.stderr}")
# Verify output
model_json_path = os.path.join(OUTPUT_DIR, "model.json")
if not os.path.exists(model_json_path):
print("ERROR: Conversion did not produce model.json")
sys.exit(1)
# List output files
files = os.listdir(OUTPUT_DIR)
total_size = sum(
os.path.getsize(os.path.join(OUTPUT_DIR, f))
for f in files
if os.path.isfile(os.path.join(OUTPUT_DIR, f))
)
print("\nConversion complete!")
print(f"Output directory: {OUTPUT_DIR}/")
print(f"Files: {len(files)}")
for f in sorted(files):
fpath = os.path.join(OUTPUT_DIR, f)
if os.path.isfile(fpath):
size = os.path.getsize(fpath)
print(f" {f:<30s} {size:>10,} bytes")
print(f"Total size: {total_size:,} bytes ({total_size / 1024 / 1024:.1f} MB)")
# Read model.json to check config
with open(model_json_path) as f:
model_json = json.load(f)
print(f"\nTF.js model format: {model_json.get('format', 'unknown')}")
print(f"Generated by: {model_json.get('generatedBy', 'unknown')}")
# Inspect model topology
if "modelTopology" in model_json:
topology = model_json["modelTopology"]
print("\nModel topology:")
print(f" Name: {topology.get('model_name', 'unnamed')}")
print(f" Ops: {len(topology.get('node', []))} nodes")
# Input/output nodes
inputs = topology.get("inputs", {})
outputs = topology.get("outputs", {})
print(f" Inputs: {list(inputs.keys())}")
for name, info in inputs.items():
shape = info.get("tensorShape", {})
print(f" {name}: shape={shape.get('dim', 'unknown')}")
print(f" Outputs: {list(outputs.keys())}")
for name, info in outputs.items():
shape = info.get("tensorShape", {})
print(f" {name}: shape={shape.get('dim', 'unknown')}")
# Check weights specification
if "weightsManifest" in model_json:
manifest = model_json["weightsManifest"]
print(f"\nWeight manifests: {len(manifest)}")
for i, m in enumerate(manifest):
shards = m.get("shards", [])
print(f" Manifest {i}: {len(shards)} shard(s)")
return OUTPUT_DIR
def main():
if not os.path.exists(MODEL_PATH):
print(f"ERROR: Model not found at {MODEL_PATH}")
sys.exit(1)
# Step 1: Inspect metadata
inspect_keras_metadata()
# Step 2: Convert
output_dir = convert_to_tfjs()
# Step 3: Summary
print("\n" + "=" * 60)
print("NEXT STEPS")
print("=" * 60)
print(f"""
1. Move the TF.js model to the expected location:
The model-loader expects model.json at:
public/models/plant-disease-classifier/model.json
Move files:
mv {output_dir}/model.json public/models/plant-disease-classifier/
mv {output_dir}/group1-shard* public/models/plant-disease-classifier/
2. IMPORTANT: This model has 38 output classes (original PlantVillage).
Your labels.ts expects 95 classes (93 diseases + healthy + unknown).
You'll need to either:
a) Fine-tune the model with your 95-class dataset, OR
b) Map the 38 PlantVillage classes to your disease IDs
3. Install @tensorflow/tfjs in your project:
npm install @tensorflow/tfjs
4. Test with your API:
npm run dev
POST /api/identify with an uploaded image
""")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,691 @@
/**
* Expand DB with comprehensive plant disease list from Wikipedia.
*
* Reads /tmp/plant_diseases/plant_diseases_comprehensive.txt,
* compares against existing DB entries (by name, case-insensitive),
* and inserts new entries with reasonable defaults.
*
* Usage:
* cd apps/web && export $(grep -v '^#' .env.development | xargs) && npx tsx scripts/expand-diseases.ts
*/
import "dotenv/config";
import { readFileSync } from "fs";
import { eq, sql } from "drizzle-orm";
import { getDb, closeDb } from "../src/lib/db/index";
import { plants, diseases } from "../src/lib/db/schema";
import type { CausalAgentType, Severity } from "../src/lib/types";
// ─── Parse the comprehensive list ─────────────────────────────────────────────
interface DiseaseEntry {
name: string;
sourceUrl: string;
}
function parseComprehensiveList(filePath: string): DiseaseEntry[] {
const content = readFileSync(filePath, "utf-8");
const entries: DiseaseEntry[] = [];
const lines = content.split("\n");
const nameRe = /^\d+\.\s+(.+)$/;
for (let i = 0; i < lines.length; i++) {
const nameMatch = lines[i].match(nameRe);
if (nameMatch) {
const name = nameMatch[1].trim();
const urlLine = lines[i + 1]?.trim() || "";
// Only add if the next line is a valid URL
if (urlLine.startsWith("http")) {
entries.push({ name, sourceUrl: urlLine });
i++; // skip the URL line
} else {
entries.push({ name, sourceUrl: "" });
}
}
}
return entries;
}
// ─── Infer causal agent type from disease name ────────────────────────────────
function inferCausalAgent(name: string): CausalAgentType {
const lower = name.toLowerCase();
// Bacterial indicators
if (
lower.startsWith("bacterial ") ||
lower.includes(" xanthomonas") ||
lower.includes(" pseudomonas") ||
lower.includes(" erwinia") ||
lower.includes(" ralstonia") ||
lower.includes(" clavibacter") ||
lower.includes(" streptomyces") ||
lower.includes(" agrobacterium") ||
lower.includes(" corynebacterium") ||
lower.includes(" pectobacterium") ||
lower.includes(" dickeya")
) {
return "bacterial";
}
// Viral indicators - strong signals
if (
lower.includes(" mosaic") ||
lower.includes(" yellows") ||
lower.includes(" leaf roll") ||
lower.includes(" leafroll") ||
lower.includes(" ringspot") ||
lower.includes(" ring spot") ||
lower.includes(" enation") ||
lower.includes(" phyllody") ||
lower.includes(" witches") ||
lower.includes(" witches'") ||
lower.includes(" crinkle") ||
lower.includes(" rosette") ||
lower.includes(" shoestring") ||
lower.includes(" tristeza") ||
lower.includes(" psorosis") ||
lower.includes(" stubborn") ||
lower.includes(" greening") ||
lower.includes(" vein banding") ||
lower.includes(" vein mottle") ||
lower.includes(" vein clearing") ||
lower.includes(" leaf pucker") ||
lower.includes(" pucker leaf") ||
lower.includes(" latent") ||
lower.includes(" motley") ||
lower.includes(" rugose")
) {
return "viral";
}
// Viral - names containing "virus" or "viroid"
if (lower.includes(" virus") || lower.includes(" viroid") || lower.includes(" virosis")) {
return "viral";
}
// Nematodes
if (
lower.includes(" nematode") ||
lower.includes(" nematodes") ||
lower.includes(" eelworm") ||
lower.includes(" root knot") ||
lower.includes(" root-knot") ||
lower.includes(" cyst ") ||
lower.includes(" dagger ") ||
lower.includes(" lance ") ||
lower.includes(" lesion ") ||
lower.includes(" ring ") ||
lower.includes(" spiral ") ||
lower.includes(" sting ") ||
lower.includes(" stubby ") ||
lower.includes(" needle ") ||
lower.includes(" foliar ") ||
lower.includes(" bulb ") ||
lower.includes(" reniform ") ||
lower.includes(" burrowing ")
) {
// Check if it's really a nematode name
if (lower.includes("nematode")) return "environmental";
}
// Fungal indicators
if (
lower.includes(" mildew") ||
lower.includes(" rust") ||
lower.includes(" smut") ||
lower.includes(" blight") ||
lower.includes(" canker") ||
lower.includes(" rot") ||
lower.includes(" scab") ||
lower.includes(" mold") ||
lower.includes(" anthracnose") ||
lower.includes(" bunt") ||
lower.includes(" ergot") ||
lower.includes(" dieback") ||
lower.includes(" scald") ||
lower.includes(" blotch") ||
lower.includes(" speckle") ||
lower.includes(" sooty") ||
lower.includes(" flyspeck") ||
lower.includes(" fusarium") ||
lower.includes(" alternaria") ||
lower.includes(" botrytis") ||
lower.includes(" rhizoctonia") ||
lower.includes(" pythium") ||
lower.includes(" phytophthora") ||
lower.includes(" sclerotinia") ||
lower.includes(" verticillium") ||
lower.includes(" ascochyta") ||
lower.includes(" cercospora") ||
lower.includes(" septoria") ||
lower.includes(" colletotrichum") ||
lower.includes(" phomopsis") ||
lower.includes(" diaporthe") ||
lower.includes(" diplodia") ||
lower.includes(" macrophomina") ||
lower.includes(" cylindrocladium") ||
lower.includes(" mycosphaerella") ||
lower.includes(" helminthosporium") ||
lower.includes(" curvularia") ||
lower.includes(" bipolaris") ||
lower.includes(" exserohilum") ||
lower.includes(" dothiorella") ||
lower.includes(" fusicoccum") ||
lower.includes(" pestalotia") ||
lower.includes(" glomerella") ||
lower.includes(" nectria") ||
lower.includes(" eutypa") ||
lower.includes(" armillaria") ||
lower.includes(" ganoderma") ||
lower.includes(" phoma") ||
lower.includes(" cladosporium") ||
lower.includes(" penicillium") ||
lower.includes(" aspergillus") ||
lower.includes(" rhizopus") ||
lower.includes(" mucor") ||
lower.includes(" downy mildew") ||
lower.includes(" powdery mildew") ||
lower.includes(" pink rot") ||
lower.includes(" pink mold") ||
lower.includes(" pink root") ||
lower.includes(" gray mold") ||
lower.includes(" grey mold") ||
lower.includes(" white rot") ||
lower.includes(" white mold") ||
lower.includes(" brown rot") ||
lower.includes(" black rot") ||
lower.includes(" soft rot") ||
lower.includes(" dry rot") ||
lower.includes(" fruit rot") ||
lower.includes(" root rot") ||
lower.includes(" stem rot") ||
lower.includes(" ear rot") ||
lower.includes(" crown rot") ||
lower.includes(" collar rot") ||
lower.includes(" pod rot") ||
lower.includes(" kernel rot") ||
lower.includes(" stalk rot") ||
lower.includes(" head rot") ||
lower.includes(" butt rot") ||
lower.includes(" stump rot") ||
lower.includes(" wood rot") ||
lower.includes(" seed rot") ||
lower.includes(" leaf spot") ||
lower.includes(" leaf blight") ||
lower.includes(" leaf blotch") ||
lower.includes(" leaf rust") ||
lower.includes(" brown spot") ||
lower.includes(" black spot") ||
lower.includes(" black leg") ||
lower.includes(" blackleg") ||
lower.includes(" black foot") ||
lower.includes(" white rust") ||
lower.includes(" white smut") ||
lower.includes(" white scab") ||
lower.includes(" tar spot") ||
lower.includes(" target spot") ||
lower.includes(" dollar spot") ||
lower.includes(" fairy ring") ||
lower.includes(" snow mold") ||
lower.includes(" pink disease") ||
lower.includes(" thread blight") ||
lower.includes(" web blight") ||
lower.includes(" sclerotial") ||
lower.includes(" sore shin") ||
lower.includes(" wart") ||
lower.includes(" scurf") ||
lower.includes(" silver scurf") ||
lower.includes(" shot hole") ||
lower.includes(" timber rot") ||
lower.includes(" cottony rot") ||
lower.includes(" watery rot") ||
lower.includes(" sour rot") ||
lower.includes(" seepage") ||
lower.includes(" bunch rot") ||
lower.includes(" noble rot") ||
lower.includes(" bitter rot") ||
lower.includes(" ripe rot") ||
lower.includes(" ring rot") ||
lower.includes(" coral spot") ||
lower.includes(" stem canker") ||
lower.includes(" branch canker") ||
lower.includes(" perennial canker") ||
lower.includes(" brand canker") ||
lower.includes(" blister canker") ||
lower.includes(" bleeding canker") ||
lower.includes(" bark canker") ||
lower.includes(" gum canker") ||
lower.includes(" collar crack") ||
lower.includes(" fasciation") ||
lower.includes(" exobasidium") ||
lower.includes(" mycorrhiza") ||
lower.includes(" lichen") ||
lower.includes(" algal") ||
lower.includes(" chlorosis") ||
lower.includes(" leaf blister") ||
lower.includes(" leaf curl")
) {
return "fungal";
}
// Physiological / environmental indicators
if (
lower.includes(" sunscald") ||
lower.includes(" sunburn") ||
lower.includes(" chilling") ||
lower.includes(" blossom end rot") ||
lower.includes(" edema") ||
lower.includes(" deficiency") ||
lower.includes(" toxicity") ||
lower.includes(" ozone") ||
lower.includes(" drought") ||
lower.includes(" frost") ||
lower.includes(" herbicide") ||
lower.includes(" pesticide") ||
lower.includes(" phytotoxicity") ||
lower.includes(" catface") ||
lower.includes(" fruit cracking") ||
lower.includes(" russeting") ||
lower.includes(" growth crack") ||
lower.includes(" mealiness") ||
lower.includes(" wind scar") ||
lower.includes(" hail") ||
lower.includes(" salt ") ||
lower.includes(" nutritional") ||
lower.includes(" mineral") ||
lower.includes(" overwatering") ||
lower.includes(" under watering") ||
lower.includes(" waterlogging") ||
lower.includes(" chemical injury") ||
lower.includes(" spray injury") ||
lower.includes(" fertilizer burn") ||
lower.includes(" lightning") ||
lower.includes(" bruising") ||
lower.includes(" pressure bruise") ||
lower.includes(" impact damage") ||
lower.includes(" transit rot")
) {
return "environmental";
}
// Insect/mite/pest indicators
if (
lower.includes(" mite") ||
lower.includes(" beetle") ||
lower.includes(" weevil") ||
lower.includes(" aphid") ||
lower.includes(" bollworm") ||
lower.includes(" leaf miner") ||
lower.includes(" mealybug") ||
lower.includes(" thrips") ||
lower.includes(" whitefly") ||
lower.includes(" caterpillar") ||
lower.includes(" sawfly") ||
lower.includes(" scale ") ||
lower.includes(" leafhopper") ||
lower.includes(" psylla") ||
lower.includes(" slug") ||
lower.includes(" snail") ||
lower.includes(" borer") ||
lower.includes(" maggot") ||
lower.includes(" grub") ||
lower.includes(" earwig") ||
lower.includes(" grasshopper")
) {
return "environmental";
}
// Fungal genus names
const fungalGenera = [
"armillaria",
"aspergillus",
"alternaria",
"botrytis",
"cercospora",
"cladosporium",
"colletotrichum",
"curvularia",
"cylindrocladium",
"diplodia",
"fusarium",
"ganoderma",
"glomerella",
"helminthosporium",
"macrophomina",
"mycosphaerella",
"nectria",
"penicillium",
"pestalotia",
"phoma",
"phomopsis",
"phytophthora",
"pythium",
"rhizoctonia",
"sclerotinia",
"septoria",
"verticillium",
"ascochyta",
"cercoseptoria",
"phaeoisariopsis",
"phaeoseptoria",
"stagonospora",
"stemphylium",
"myrothecium",
"myriogenospora",
"dactuliophora",
"dilophospora",
"coniothecium",
"coniosporium",
"cryptostictis",
"catacauma",
"botryodiplodia",
"botryosphaeria",
"cephalosporium",
"ceratocystis",
"chalara",
"choanephora",
"clitocybe",
"coprinus",
"cordana",
"corticium",
"corynespora",
"coryneum",
"cylindrocarpon",
"cylindrocladiella",
"cylindrosporium",
"cytospora",
"cytosporina",
"dematophora",
"didymella",
"dothiorella",
"drechslera",
"endothia",
"eutypa",
"eutypella",
"exobasidium",
"fusicladium",
"fusicoccum",
"gibberella",
"glomerella",
"gnomonia",
"graphiola",
"guignardia",
"hendersonia",
"hendersonula",
"hymenochaete",
"hypoxylon",
"lasiodiplodia",
"leptosphaeria",
"leucostoma",
"lophodermium",
"macrophoma",
"marasmiellus",
"marasmius",
"massaria",
"monilia",
"monosporascus",
"mystrosporium",
"neocosmospora",
"nigrospora",
"omphalia",
"ophiobolus",
"ovulinia",
"ozonium",
"panagrolaimus",
"periconia",
"pestalosphaeria",
"pestalotiopsis",
"phialophora",
"phymatotrichum",
"physalospora",
"phytophthora",
"plasmodiophora",
"plectosporium",
"polyporus",
"poria",
"pseudocercosporella",
"pseudopeziza",
"pseudoseptoria",
"puccinia",
"pyrenochaeta",
"pythium",
"ramularia",
"rhizoctonia",
"rhizopus",
"rhynchosporium",
"rosellinia",
"sclerophthora",
"sclerotinia",
"sclerotium",
"septoria",
"sphaceloma",
"sphaeropsis",
"spongospora",
"stagonospora",
"stemphylium",
"stereum",
"stigmina",
"thanatephorus",
"thielaviopsis",
"tippula",
"typhula",
"ulocladium",
"uredo",
"ustilago",
"valsa",
"venturia",
"verticillium",
"xylaria",
];
for (const genus of fungalGenera) {
if (lower.includes(genus)) return "fungal";
}
// Default to fungal (most plant diseases are fungal)
return "fungal";
}
// ─── Infer severity ───────────────────────────────────────────────────────────
function inferSeverity(name: string): Severity {
const lower = name.toLowerCase();
if (
lower.includes(" lethal") ||
lower.includes(" devastating") ||
lower.includes(" destructive") ||
lower.includes(" fatal") ||
lower.includes(" severe") ||
lower.includes(" blight") ||
lower.includes(" wilt") ||
lower.includes(" canker") ||
lower.includes(" dieback") ||
lower.includes(" decline") ||
lower.includes(" rot") ||
lower.includes(" gall") ||
lower.includes(" gummosis") ||
lower.includes(" necrosis") ||
lower.includes(" erwinia")
) {
return "high";
}
if (
lower.includes(" minor") ||
lower.includes(" mild") ||
lower.includes(" slight") ||
lower.includes(" speckle") ||
lower.includes(" fleck") ||
lower.includes(" freckle") ||
lower.includes(" chlorosis") ||
lower.includes(" translucence") ||
lower.includes(" superficial")
) {
return "low";
}
return "moderate";
}
// ─── Generate a deterministic slug ────────────────────────────────────────────
function toSlug(name: string): string {
return (
"wiki-" +
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/-+/g, "-")
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
// 1. Get existing disease names from DB
const existingDiseases = await db.select({ name: diseases.name }).from(diseases);
const existingNames = new Set(existingDiseases.map((d) => d.name.toLowerCase().trim()));
console.log(`Existing diseases in DB: ${existingNames.size}`);
// 2. Parse the comprehensive list
const entries = parseComprehensiveList("/tmp/plant_diseases/plant_diseases_comprehensive.txt");
console.log(`Total entries in comprehensive file: ${entries.length}`);
// 3. Find or create catch-all plants
for (const plantId of ["general", "unknown"]) {
const existing = await db.select().from(plants).where(eq(plants.id, plantId)).get();
if (!existing) {
console.log(`Creating '${plantId}' plant for catch-all diseases...`);
await db.insert(plants).values({
id: plantId,
commonName: plantId === "general" ? "General (Multiple Plants)" : "Unknown Plant",
scientificName: "Various",
family: "Various",
category: "houseplant",
careSummary:
plantId === "general"
? "General plant diseases affecting multiple species."
: "Plant disease with unknown host plant.",
imageUrl: "",
});
console.log(`Created '${plantId}' plant.`);
}
}
// 4. Filter new entries (deduplicate within file + against DB)
const newEntries: DiseaseEntry[] = [];
const skipped: string[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const key = entry.name.toLowerCase().trim();
if (seen.has(key)) continue;
seen.add(key);
if (existingNames.has(key)) {
skipped.push(entry.name);
} else {
newEntries.push(entry);
}
}
console.log(`\nNew entries to insert: ${newEntries.length}`);
console.log(`Already existing (skipped): ${skipped.length}`);
if (skipped.length > 0) {
console.log(`\nFirst 10 skipped (of ${skipped.length}):`);
skipped.slice(0, 10).forEach((s) => console.log(` - ${s}`));
}
// 5. Insert new entries in batches
if (newEntries.length === 0) {
console.log("\n✅ No new diseases to insert.");
closeDb();
return;
}
const BATCH_SIZE = 50;
let inserted = 0;
let errors = 0;
for (let i = 0; i < newEntries.length; i += BATCH_SIZE) {
const batch = newEntries.slice(i, i + BATCH_SIZE);
const values = batch.map((entry) => {
const causalAgent = inferCausalAgent(entry.name);
const severity = inferSeverity(entry.name);
return {
id: toSlug(entry.name),
plantId: "general",
name: entry.name,
scientificName: "",
causalAgentType: causalAgent,
description: `A plant disease known as "${entry.name}". Source: Wikipedia.`,
symptoms: [],
causes: [],
treatment: [],
prevention: [],
lookalikeIds: [],
severity,
sourceUrl: entry.sourceUrl,
imageUrl: "",
};
});
try {
await db.insert(diseases).values(values).onConflictDoNothing();
inserted += values.length;
} catch (err) {
// Fall back to individual inserts for this batch if batch fails
console.log(` Batch failed, trying individually...`);
for (const val of values) {
try {
await db.insert(diseases).values(val).onConflictDoNothing();
inserted++;
} catch (e2) {
// If it's a duplicate key, count it as skipped
if (String(e2).includes("UNIQUE") || String(e2).includes("duplicate")) {
// Already handled by onConflictDoNothing, shouldn't happen
inserted++;
} else {
console.error(` Error inserting "${val.name}":`, e2);
errors++;
}
}
}
}
if ((i + BATCH_SIZE) % 200 === 0 || i + BATCH_SIZE >= newEntries.length) {
console.log(
` Progress: ${Math.min(i + BATCH_SIZE, newEntries.length)}/${newEntries.length} (${inserted} inserted, ${errors} errors)`,
);
}
}
// 6. Summary
const totalDiseases = await db
.select({ count: sql<number>`COUNT(*)` })
.from(diseases)
.get();
const totalPlants = await db
.select({ count: sql<number>`COUNT(*)` })
.from(plants)
.get();
console.log(`\n📊 Results:`);
console.log(` Inserted: ${inserted}`);
console.log(` Errors: ${errors}`);
console.log(` Skipped (already existed): ${skipped.length}`);
console.log(`\n📊 Database now has:`);
console.log(` ${totalPlants?.count ?? 0} plants`);
console.log(` ${totalDiseases?.count ?? 0} diseases`);
closeDb();
}
main().catch((err) => {
console.error("❌ Failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,414 @@
#!/usr/bin/env node
/**
* fill-brave-images-v2.ts — Brave Image Search for remaining disease images.
*
* Prioritizes by severity (critical → high → moderate → low).
* Runs at 1 request/sec (Brave free tier rate limit).
* Updates Turso DB directly with found images.
* When current key is exhausted, prompts for next key.
* Falls back to duckduckgo-images-api when all keys are spent.
*
* Usage:
* cd apps/web && npx tsx scripts/fill-brave-images-v2.ts
*
* Pass additional API keys as args:
* npx tsx scripts/fill-brave-images-v2.ts KEY2 KEY3
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
const envPath = resolve(__dirname, "../.env.development");
try {
const env = readFileSync(envPath, "utf-8");
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
}
}
} catch {}
// Also try .env.local for BRAVE_API_KEY
try {
const envLocal = readFileSync(resolve(__dirname, "../.env.local"), "utf-8");
for (const line of envLocal.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("BRAVE_API_KEY=")) {
const val = trimmed.slice("BRAVE_API_KEY=".length).trim();
if (!process.env.BRAVE_API_KEY) process.env.BRAVE_API_KEY = val;
}
}
} catch {}
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
interface DiseaseRow {
id: string;
name: string;
scientificName: string;
severity: string;
plantId: string;
}
// ─── Config ──────────────────────────────────────────────────────────────────
const BRAVE_DELAY = 1100; // ms between calls (1 req/sec)
const DB_FLUSH_BATCH = 50;
const MAX_PER_KEY = 1800; // Leave 200 buffer of the 2000/mo limit
const STATE_FILE = resolve(__dirname, ".brave-progress.json");
let currentKeyIndex = 0;
let braveKeys: string[] = [];
let callsThisKey = 0;
let totalFound = 0;
// totalSkipped tracking removed — not needed for v2
// ─── State persistence ───────────────────────────────────────────────────────
interface RunState {
processedIds: string[];
currentKeyIndex: number;
callsThisKey: number;
totalFound: number;
}
function loadState(): RunState | null {
try {
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
} catch {
return null;
}
}
function saveState(processedIds: string[]) {
writeFileSync(
STATE_FILE,
JSON.stringify(
{
processedIds,
currentKeyIndex,
callsThisKey,
totalFound,
},
null,
2,
),
"utf-8",
);
}
// ─── Brave API ───────────────────────────────────────────────────────────────
async function braveImageSearch(query: string): Promise<string | null> {
const key = braveKeys[currentKeyIndex];
if (!key) return null;
const url = new URL("https://api.search.brave.com/res/v1/images/search");
url.searchParams.set("q", query);
url.searchParams.set("count", "3");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: { "X-Subscription-Token": key, Accept: "application/json" },
});
if (res.status === 429) {
console.log("\n [RATE LIMITED] Key " + (currentKeyIndex + 1) + " exhausted!");
return "RATE_LIMITED";
}
if (!res.ok) return null;
callsThisKey++;
const data = (await res.json()) as {
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
};
const results = data?.results ?? [];
if (results.length === 0) return null;
// Prefer non-stock images
for (const r of results) {
const src = r.thumbnail?.src ?? r.url;
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) {
return src;
}
}
return results[0].thumbnail?.src ?? results[0].url;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
// ─── DuckDuckGo fallback ────────────────────────────────────────────────────
async function ddgFallbackSearch(query: string): Promise<string | null> {
try {
// Try to use duckduckgo-images-api if installed
const ddg = await import("duckduckgo-images-api").catch(() => null);
if (ddg) {
const results = await ddg.image_search({ query, moderate: true });
if (results && results.length > 0) {
for (const r of results) {
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
return r.image;
}
}
return results[0].image || null;
}
}
} catch {
// duckduckgo-images-api not installed
}
return null;
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log("\n🔍 Brave Disease Image Filler v2\n");
// Parse keys from args + env
const argsKeys = process.argv.slice(2).filter((a) => !a.startsWith("-"));
const envKey = process.env.BRAVE_API_KEY;
braveKeys = [envKey, ...argsKeys].filter(Boolean) as string[];
braveKeys = [...new Set(braveKeys)]; // dedup
if (braveKeys.length === 0) {
console.log("❌ No Brave API keys found.");
console.log(" Set BRAVE_API_KEY in .env.local or pass as argument.\n");
process.exit(1);
}
console.log(`🔑 ${braveKeys.length} Brave API key(s) available\n`);
// Load state
const state = loadState();
if (state) {
currentKeyIndex = state.currentKeyIndex;
callsThisKey = state.callsThisKey;
totalFound = state.totalFound;
console.log(
`📋 Resuming from previous run (${state.processedIds.length} processed, ${totalFound} found)\n`,
);
}
// Get diseases from DB
const db = getDb();
const allDiseases = (await db
.select({
id: diseases.id,
name: diseases.name,
scientificName: diseases.scientificName,
severity: diseases.severity,
plantId: diseases.plantId,
})
.from(diseases)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as DiseaseRow[];
console.log(`📋 ${allDiseases.length} diseases need images\n`);
if (allDiseases.length === 0) {
console.log("✅ All diseases already have images!\n");
closeDb();
return;
}
// Sort by severity priority
const severityOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
allDiseases.sort(
(a, b) =>
(severityOrder[a.severity as keyof typeof severityOrder] || 99) -
(severityOrder[b.severity as keyof typeof severityOrder] || 99),
);
// Filter out already-processed from state
const processedSet = new Set(state?.processedIds || []);
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
console.log(
`📊 Prioritization: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
);
if (pending.length === 0) {
console.log("✅ All remaining diseases already attempted\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let updates: Array<{ id: string; url: string }> = [];
const processedIds: string[] = state?.processedIds || [];
let found = totalFound;
let ddgMode = false;
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
// Check if current key needs rotating
if (!ddgMode && callsThisKey >= MAX_PER_KEY) {
if (currentKeyIndex < braveKeys.length - 1) {
currentKeyIndex++;
callsThisKey = 0;
console.log(`\n 🔄 Rotating to key ${currentKeyIndex + 1}/${braveKeys.length}\n`);
} else {
console.log(
`\n ⚠️ All ${braveKeys.length} Brave keys exhausted. Switching to DuckDuckGo fallback.\n`,
);
ddgMode = true;
// Install duckduckgo-images-api if not available
try {
await import("duckduckgo-images-api");
} catch {
console.log(" Installing duckduckgo-images-api...");
const { execSync } = await import("child_process");
execSync("npm install duckduckgo-images-api", {
cwd: resolve(__dirname, ".."),
stdio: "pipe",
});
console.log(" Done.\n");
}
}
}
// Build search query
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const query = `${d.name} ${d.scientificName} ${plantName} plant disease`;
const sev = d.severity.padEnd(8);
process.stdout.write(
` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 40).padEnd(42)} `,
);
let url: string | null = null;
if (ddgMode) {
url = await ddgFallbackSearch(query);
if (!url) {
// Try a simpler query
url = await ddgFallbackSearch(`${d.name} disease`);
}
} else {
url = await braveImageSearch(query);
if (url === "RATE_LIMITED") {
// Key exhausted mid-query, try next
if (currentKeyIndex < braveKeys.length - 1) {
currentKeyIndex++;
callsThisKey = 0;
console.log("\n 🔄 Rotating key...");
url = await braveImageSearch(query);
} else {
console.log("\n ⚠️ All keys exhausted mid-batch!");
ddgMode = true;
url = await ddgFallbackSearch(query);
}
}
}
if (url) {
updates.push({ id: d.id, url });
found++;
processedIds.push(d.id);
console.log("✅");
} else {
processedIds.push(d.id); // Mark as attempted even if not found
console.log("❌");
}
// Flush to DB
if (updates.length >= DB_FLUSH_BATCH) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates = [];
}
// Save state every 50
if ((i + 1) % 50 === 0) {
saveState(processedIds);
}
// Rate limit (even for DDG to be polite)
await new Promise((r) => setTimeout(r, ddgMode ? 500 : BRAVE_DELAY));
}
// Final flush
if (updates.length > 0) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
}
saveState(processedIds);
raw.close();
// Final report
const finalList = await db
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
.from(diseases)
.all();
const w = finalList.filter((d) => d.imageUrl);
const wo = finalList.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 BRAVE IMAGE SEARCH COMPLETE`);
console.log(`${"═".repeat(50)}`);
console.log(` Processed: ${pending.length}`);
console.log(` Found this run: ${found - totalFound}`);
console.log(` Total with images: ${w.length}/${finalList.length}`);
console.log(` Still missing: ${wo.length}`);
console.log(` Brave keys used: ${currentKeyIndex + 1}`);
console.log(` Calls on current key: ${callsThisKey}`);
console.log(` DuckDuckGo mode: ${ddgMode}`);
if (wo.length > 0) {
const rp = resolve(__dirname, ".disease-image-review-needed.md");
let report = "# Disease Images - Still Missing\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## Summary\n\n`;
report += `- Total: ${finalList.length}\n`;
report += `- With images: ${w.length}\n`;
report += `- Still missing: ${wo.length}\n\n`;
report += `## Missing Diseases\n\n`;
for (const d of wo) {
report += `- ${d.name} (\`${d.id}\`)\n`;
}
writeFileSync(rp, report, "utf-8");
console.log(`\n📝 Report: ${rp}`);
} else {
console.log("\n✅ ALL diseases now have images!");
}
closeDb();
console.log("\n");
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* fill-brave-images.ts — Brave-only pass for remaining disease images.
*
* Runs at 1 request/sec (Brave rate limit).
* Updates diseases.json and Turso DB.
*
* Usage: cd apps/web && npx tsx scripts/fill-brave-images.ts
*/
import dotenv from "dotenv"; dotenv.config({ path: resolve(__dirname, "../.env.local") });
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
import { createClient } from "@libsql/client";
import { closeDb } from "../src/lib/db/index";
const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
const BRAVE_KEY = process.env.BRAVE_API_KEY ?? "";
interface DiseaseSeed {
id: string;
plantId: string;
name: string;
scientificName: string;
imageUrl?: string;
[key: string]: unknown;
}
function load(): DiseaseSeed[] {
return JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
}
async function searchBraveImage(query: string): Promise<string | null> {
const url = new URL("https://api.search.brave.com/res/v1/images/search");
url.searchParams.set("q", query);
url.searchParams.set("count", "3");
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url.toString(), {
headers: { "X-Subscription-Token": BRAVE_KEY, Accept: "application/json" },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 5000 * 2 ** attempt));
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
};
const results = data?.results ?? [];
if (results.length === 0) return null;
// Prefer non-stock direct-looking images
for (const r of results) {
const src = r.thumbnail?.src ?? r.url;
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) return src;
}
return results[0].thumbnail?.src ?? results[0].url;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
async function main() {
console.log("\n🔍 Brave Image Search — remaining disease images\n");
if (!BRAVE_KEY) {
console.log("❌ No BRAVE_API_KEY in .env.local\n");
process.exit(1);
}
const diseases = load();
const pending = diseases.filter((d) => !d.imageUrl);
console.log(`📋 ${pending.length} diseases need images\n`);
let found = 0;
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
const plant = diseases.find((p) => p.id === d.plantId);
const plantName = plant?.name ?? d.plantId;
const query = `${d.name} ${plantName} plant disease symptom`;
process.stdout.write(` [${String(i + 1).padStart(2, " ")}/${pending.length}] ${d.name.padEnd(35)} `);
const url = await searchBraveImage(query);
if (url) {
d.imageUrl = url;
found++;
console.log(``);
} else {
console.log(``);
}
// 1 req/sec rate limit
await new Promise((r) => setTimeout(r, 1100));
}
// Write updated JSON
writeFileSync(DISEASES_JSON, JSON.stringify(diseases, null, 2) + "\n", "utf-8");
console.log(`\n✅ diseases.json updated: ${found}/${pending.length} images found\n`);
// Update DB
try {
const dbUrl = process.env.DATABASE_URL;
const dbToken = process.env.DATABASE_TOKEN;
if (dbUrl && dbToken) {
const raw = createClient({ url: dbUrl, authToken: dbToken });
const updates = pending.filter((d) => d.imageUrl);
for (let i = 0; i < updates.length; i += 50) {
await raw.batch(
updates.slice(i, i + 50).map((d) => ({
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
args: [d.imageUrl!, d.id],
})),
"write",
);
}
raw.close();
console.log(`✅ Turso DB updated: ${updates.length} rows`);
} else {
console.log("⏭️ Skipping DB — no DATABASE_URL/TOKEN");
}
} catch (err) {
console.log(` ⚠️ DB: ${err instanceof Error ? err.message : err}`);
}
// Summary
const finalDiseases = JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
const stillMissing = finalDiseases.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 FINAL: ${finalDiseases.length} total`);
console.log(` With images: ${finalDiseases.length - stillMissing.length}`);
console.log(` Still missing: ${stillMissing.length}`);
if (stillMissing.length > 0) {
console.log(`\nStill need human curation:`);
for (const d of stillMissing) {
console.log(`${d.name} (${d.id})`);
}
}
console.log(`${"═".repeat(50)}\n`);
closeDb();
}
main().catch((err) => {
console.error("\n❌ Fatal:", err);
process.exit(1);
});

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* fill-ddg-images.ts — DuckDuckGo Image Search for remaining disease images.
*
* No API key needed. Searches DuckDuckGo Images API for each disease
* without an image and updates the Turso DB.
*
* Prioritizes by severity (critical → high → moderate → low).
* Runs at 1 request/sec to be polite to DuckDuckGo.
* Resumable via state file (scripts/.ddg-progress.json).
*
* Usage:
* cd apps/web && npx tsx scripts/fill-ddg-images.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load .env.development for DB creds
const envPath = resolve(__dirname, "../.env.development");
try {
const env = readFileSync(envPath, "utf-8");
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
}
}
} catch {}
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
// DuckDuckGo
import { imageSearch } from "@mudbill/duckduckgo-images-api";
interface DiseaseRow {
id: string;
name: string;
scientificName: string;
severity: string;
plantId: string;
}
// ─── Config ──────────────────────────────────────────────────────────────────
const POLITE_DELAY = 1100; // ms between calls
const DB_FLUSH_BATCH = 50;
const STATE_FILE = resolve(__dirname, ".ddg-progress.json");
interface RunState {
processedIds: string[];
totalFound: number;
}
function loadState(): RunState | null {
try {
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
} catch {
return null;
}
}
function saveState(processedIds: string[], totalFound: number) {
writeFileSync(STATE_FILE, JSON.stringify({ processedIds, totalFound }, null, 2), "utf-8");
}
// ─── DuckDuckGo Search ───────────────────────────────────────────────────────
async function searchImage(query: string): Promise<string | null> {
try {
const results = await imageSearch({ query, safe: true, iterations: 1, retries: 2 });
if (!results || results.length === 0) return null;
// Prefer non-stock images
for (const r of results) {
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
return r.image;
}
}
return results[0].image || results[0].thumbnail || null;
} catch {
// DuckDuckGo may block or timeout; silently skip
return null;
}
}
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
console.log("\n🦆 DuckDuckGo Disease Image Filler\n");
const db = getDb();
// Load state
const state = loadState();
const processedSet = new Set(state?.processedIds || []);
const totalFoundPrev = state?.totalFound ?? 0;
// Get all diseases that still need images
const allDiseases = (await db
.select({
id: diseases.id,
name: diseases.name,
scientificName: diseases.scientificName,
severity: diseases.severity,
plantId: diseases.plantId,
})
.from(diseases)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as DiseaseRow[];
console.log(`📋 ${allDiseases.length} diseases need images\n`);
if (allDiseases.length === 0) {
console.log("✅ All diseases already have images!\n");
closeDb();
return;
}
// Sort by severity: critical > high > moderate > low
const severityOrder: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 };
allDiseases.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99));
// Filter out already-processed
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
console.log(
`📊 Remaining: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, ` +
`high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, ` +
`moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, ` +
`low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
);
if (pending.length === 0) {
console.log("✅ All remaining diseases already attempted\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
const processedIds: string[] = state?.processedIds ?? [];
let found = totalFoundPrev;
let updates: Array<{ id: string; url: string }> = [];
for (let i = 0; i < pending.length; i++) {
const d = pending[i];
const sev = d.severity.padEnd(8);
// Build search query — "[disease] on [plant]" phrasing for better specificity
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const query1 = `${d.name} on ${plantName} plant disease`;
const query2 = `${d.scientificName || d.name} on ${plantName} disease`;
const query3 = `${d.name} plant disease ${plantName}`;
process.stdout.write(
` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 42).padEnd(44)} `,
);
// Try queries in order until we get a result
let url: string | null = null;
for (const q of [query1, query2, query3]) {
url = await searchImage(q);
if (url) break;
}
if (url) {
updates.push({ id: d.id, url });
found++;
processedIds.push(d.id);
console.log("✅");
} else {
processedIds.push(d.id);
console.log("❌");
}
// Flush to DB in batches
if (updates.length >= DB_FLUSH_BATCH) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates = [];
}
// Save state every 50
if ((i + 1) % 50 === 0) {
saveState(processedIds, found);
}
// Be polite — 1 req/sec
await new Promise((r) => setTimeout(r, POLITE_DELAY));
}
// Final flush
if (updates.length > 0) {
await raw.batch(
updates.map((u) => ({
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
}
saveState(processedIds, found);
raw.close();
// Final report
const finalList = await db
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
.from(diseases)
.all();
const w = finalList.filter((d) => d.imageUrl);
const wo = finalList.filter((d) => !d.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`🦆 DUCKDUCKGO SEARCH COMPLETE`);
console.log(`${"═".repeat(50)}`);
console.log(` Processed: ${pending.length}`);
console.log(` Found this run: ${found - totalFoundPrev}`);
console.log(` Total with images: ${w.length}/${finalList.length}`);
console.log(` Still missing: ${wo.length}`);
if (wo.length > 0) {
const reportPath = resolve(__dirname, ".ddg-image-review-needed.md");
let report = "# Disease Images - Still Missing (DDG)\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## Summary\n\n`;
report += `- Total: ${finalList.length}\n`;
report += `- With images: ${w.length}\n`;
report += `- Still missing: ${wo.length}\n\n`;
report += `## Missing Diseases\n\n`;
for (const d of wo) {
report += `- ${d.name} (\`${d.id}\`)\n`;
}
writeFileSync(reportPath, report, "utf-8");
console.log(`\n📝 Missing report: ${reportPath}`);
} else {
console.log("\n✅ ALL diseases now have images!");
}
closeDb();
console.log();
}
main().catch((err) => {
console.error("\n❌ Fatal:", err);
process.exit(1);
});

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
/**
* fill-plant-images-v2.ts — Batch Wikipedia image fetch for remaining plants.
*
* Phase 1: Query 50 scientific names at a time via pageimages.
* Phase 2: Query 50 common names at a time.
* Phase 3: Search individually for stragglers.
*
* Usage: cd apps/web && npx tsx scripts/fill-plant-images-v2.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
const envPath = resolve(__dirname, "../.env.development");
try {
const env = readFileSync(envPath, "utf-8");
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key]) {
process.env[key] = val;
}
}
}
}
} catch (e) {}
import { getDb, closeDb } from "../src/lib/db/index";
import { plants } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
const API = "https://en.wikipedia.org/w/api.php";
const UA = "PlantHealthKB/1.0";
const BATCH = 50;
interface PlantRow {
id: string;
commonName: string;
scientificName: string;
}
function clean(s: string): string {
return s
.replace(/[xX]/g, "x")
.replace(/\s*spp\.?\s*/gi, "")
.replace(/[.\u00d7']/g, "")
.trim();
}
async function fetchThumbs(titles: string[]): Promise<Map<string, string>> {
if (titles.length === 0) {
return new Map();
}
const p = new URLSearchParams({
action: "query",
titles: titles.join("|"),
prop: "pageimages",
pithumbsize: "400",
redirects: "1",
format: "json",
});
for (let a = 0; a < 3; a++) {
try {
const r = await fetch(API + "?" + p.toString(), {
headers: { "User-Agent": UA },
});
if (r.status === 429) {
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
continue;
}
if (!r.ok) {
return new Map();
}
const d = (await r.json()) as any;
const pages = d?.query?.pages;
if (!pages) {
return new Map();
}
const m = new Map<string, string>();
for (const [, pg] of Object.entries(pages)) {
const p2 = pg as any;
if (!p2.missing && p2.thumbnail?.source) {
m.set(p2.title.toLowerCase(), p2.thumbnail.source);
}
}
return m;
} catch (e) {
await new Promise((rr) => setTimeout(rr, 2000));
}
}
return new Map();
}
async function searchOne(query: string): Promise<string | null> {
const p = new URLSearchParams({
action: "query",
generator: "search",
gsrsearch: query,
gsrlimit: "3",
prop: "pageimages",
pithumbsize: "400",
format: "json",
});
for (let a = 0; a < 3; a++) {
try {
const r = await fetch(API + "?" + p.toString(), {
headers: { "User-Agent": UA },
});
if (r.status === 429) {
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
continue;
}
if (!r.ok) {
return null;
}
const d = (await r.json()) as any;
const pages = d?.query?.pages;
if (!pages) {
return null;
}
for (const [, pg] of Object.entries(pages)) {
const p2 = pg as any;
if (p2.thumbnail?.source) {
return p2.thumbnail.source;
}
}
return null;
} catch (e) {
await new Promise((rr) => setTimeout(rr, 2000));
}
}
return null;
}
async function batchPhase(
plants: PlantRow[],
titleFn: (p: PlantRow) => string,
label: string,
dbClient: any,
): Promise<PlantRow[]> {
const remaining: PlantRow[] = [];
const updates: Array<{ id: string; url: string }> = [];
for (let i = 0; i < plants.length; i += BATCH) {
const chunk = plants.slice(i, i + BATCH);
const titles = chunk.map(titleFn).filter((t) => t.length > 2);
console.log(
" [" +
label +
"] " +
(i + 1) +
"-" +
Math.min(i + BATCH, plants.length) +
"/" +
plants.length +
" ",
);
const imageMap = await fetchThumbs(titles);
let n = 0;
for (const pl of chunk) {
const t = titleFn(pl).toLowerCase();
const img = imageMap.get(t);
if (img) {
updates.push({ id: pl.id, url: img });
n++;
} else {
remaining.push(pl);
}
}
console.log(" found: " + n);
if (updates.length >= 100) {
await dbClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
updates.length = 0;
}
await new Promise((r) => setTimeout(r, 1500));
}
if (updates.length > 0) {
await dbClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
}
return remaining;
}
async function main() {
console.log("\nPlant Image Filler v2\n");
const db = getDb();
const allPlants = (await db
.select({
id: plants.id,
commonName: plants.commonName,
scientificName: plants.scientificName,
})
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all()) as PlantRow[];
console.log("Plants needing images: " + allPlants.length + "\n");
if (allPlants.length === 0) {
console.log("All plants have images!\n");
closeDb();
return;
}
const raw = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let found = 0;
// Phase 1: Scientific name
console.log("--- Phase 1: Scientific names ---\n");
let remaining = await batchPhase(allPlants, (p) => clean(p.scientificName), "sci", raw);
// Phase 2: Common name
if (remaining.length > 0) {
console.log("\n--- Phase 2: Common names (" + remaining.length + ") ---\n");
remaining = await batchPhase(remaining, (p) => p.commonName, "common", raw);
}
// Phase 3: Search
if (remaining.length > 0) {
console.log("\n--- Phase 3: Search (" + remaining.length + ") ---\n");
for (let i = 0; i < remaining.length; i++) {
const pl = remaining[i];
const q = clean(pl.scientificName) + " " + pl.commonName;
console.log(" [" + (i + 1) + "/" + remaining.length + "] " + pl.commonName);
const img = await searchOne(q);
if (img) {
await raw.execute({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [img, pl.id],
});
found++;
console.log(" OK");
} else {
console.log(" MISS");
}
await new Promise((r) => setTimeout(r, 500));
}
}
raw.close();
// Report
const finalList = await db
.select({
id: plants.id,
commonName: plants.commonName,
imageUrl: plants.imageUrl,
})
.from(plants)
.all();
const w = finalList.filter((p) => p.imageUrl);
const wo = finalList.filter((p) => !p.imageUrl);
console.log("\n" + "=".repeat(50));
console.log("FINAL: " + finalList.length + " plants");
console.log(" With images: " + w.length);
console.log(" Missing: " + wo.length);
if (wo.length > 0) {
const rp = resolve(__dirname, ".plant-image-review-needed.md");
let report = "# Plant Images - Still Missing\n\n";
report += "Generated: " + new Date().toISOString() + "\n\n";
report += "## Missing (" + wo.length + ")\n\n";
for (const p of wo) {
report += "- " + p.commonName + " (" + p.id + ")\n";
}
writeFileSync(rp, report, "utf-8");
console.log("Report: " + rp);
} else {
console.log("\nALL PLANTS HAVE IMAGES!");
}
closeDb();
}
main().catch((err: any) => {
console.error("Error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env node
/**
* fill-plant-images.ts — Fetch plant images from Wikipedia for plants missing them.
*
* Uses the Wikipedia API to search for the plant's scientific name
* and grab the page thumbnail.
*
* Usage: cd apps/web && npx tsx scripts/fill-plant-images.ts
*/
import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
// Load env
const envPath = resolve(__dirname, "../.env.development");
try {
const env = readFileSync(envPath, "utf-8");
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
}
}
} catch {}
import { getDb, closeDb } from "../src/lib/db/index";
import { plants } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
import { sql } from "drizzle-orm";
const WIKI_API = "https://en.wikipedia.org/w/api.php";
const UA = "PlantHealthKB/1.0 (plant-images)";
const DELAY_MS = 500;
const BATCH_SIZE = 50;
/** Direct page lookup by title — more reliable for known scientific names. */
async function directPageLookup(title: string): Promise<string | null> {
const params = new URLSearchParams({
action: "query",
titles: title,
prop: "pageimages",
pithumbsize: "400",
format: "json",
origin: "*",
});
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${WIKI_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
continue;
}
if (!res.ok) return null;
const data = (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string }; missing?: boolean }> };
};
const pages = data?.query?.pages;
if (!pages) return null;
for (const [, p] of Object.entries(pages)) {
if (!p.missing && p.thumbnail?.source) return p.thumbnail.source;
}
return null;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
return null;
}
async function main() {
console.log("\n🌿 Fetching plant images from Wikipedia\n");
const db = getDb();
const allPlants = await db
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all();
console.log(`📋 ${allPlants.length} plants need images\n`);
if (allPlants.length === 0) {
console.log("✅ All plants already have images!\n");
closeDb();
return;
}
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
let found = 0;
const updates: { id: string; url: string }[] = [];
// Phase 1: Try direct page lookup by scientific name (most accurate)
console.log("─── Phase 1: Direct page lookup ───\n");
for (let i = 0; i < allPlants.length; i++) {
const plant = allPlants[i];
const sciName = plant.scientificName
.replace(/[×'"]/g, "")
.replace(/\s*spp\.?\s*/i, "")
.trim();
process.stdout.write(
` [${String(i + 1).padStart(3)}/${allPlants.length}] ${plant.commonName.padEnd(30)} `,
);
let url: string | null = null;
// Try scientific name first
if (sciName && sciName !== "Unknown" && sciName !== "Various") {
url = await directPageLookup(sciName);
}
// Try common name if scientific name didn't work
if (!url) {
url = await directPageLookup(plant.commonName);
}
// Try genus name
if (!url && sciName) {
const genus = sciName.split(/\s+/)[0];
if (genus && genus.length > 3) {
url = await directPageLookup(genus);
}
}
if (url) {
updates.push({ id: plant.id, url });
found++;
process.stdout.write("✅\n");
} else {
process.stdout.write("⏭️\n");
}
// Flush to DB in batches
if (updates.length >= BATCH_SIZE) {
await rawClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates.length = 0;
}
await new Promise((r) => setTimeout(r, DELAY_MS));
}
// Flush remaining
if (updates.length > 0) {
await rawClient.batch(
updates.map((u) => ({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.url, u.id],
})),
"write",
);
console.log(` → Flushed ${updates.length} to DB`);
updates.length = 0;
}
console.log(`\n✅ Phase 1 done: ${found}/${allPlants.length} plants got images\n`);
// Phase 2: Try remaining via search API
const stillMissing = await db
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
.from(plants)
.where(sql`(image_url IS NULL OR image_url = '')`)
.all();
if (stillMissing.length > 0) {
console.log(`─── Phase 2: Search API for ${stillMissing.length} remaining ───\n`);
for (let i = 0; i < stillMissing.length; i++) {
const plant = stillMissing[i];
const sciName = plant.scientificName.replace(/[×'"]/g, "").trim();
process.stdout.write(
` [${String(i + 1).padStart(3)}/${stillMissing.length}] ${plant.commonName.padEnd(30)} `,
);
// Search with scientific name
const searchTerm = `${sciName} ${plant.commonName}`;
const params = new URLSearchParams({
action: "query",
list: "search",
srsearch: searchTerm,
srlimit: "3",
format: "json",
origin: "*",
});
let url: string | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(`${WIKI_API}?${params}`, {
headers: { "User-Agent": UA },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
continue;
}
if (!res.ok) break;
const data = (await res.json()) as {
query?: { search?: Array<{ title: string; pageid: number }> };
};
const hits = data?.query?.search ?? [];
if (hits.length === 0) break;
// Get thumbnail for first result
for (const hit of hits) {
const pageParams = new URLSearchParams({
action: "query",
pageids: String(hit.pageid),
prop: "pageimages",
pithumbsize: "400",
format: "json",
origin: "*",
});
const pageRes = await fetch(`${WIKI_API}?${pageParams}`, {
headers: { "User-Agent": UA },
});
if (!pageRes.ok) continue;
const pageData = (await pageRes.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
};
const pages = pageData?.query?.pages;
if (!pages) continue;
for (const [, p] of Object.entries(pages)) {
if (p.thumbnail?.source) {
url = p.thumbnail.source;
break;
}
}
if (url) break;
}
break;
} catch {
await new Promise((r) => setTimeout(r, 2000));
}
}
if (url) {
await rawClient.execute({
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
args: [url, plant.id],
});
found++;
process.stdout.write("✅\n");
} else {
process.stdout.write("❌\n");
}
await new Promise((r) => setTimeout(r, DELAY_MS));
}
}
// Final count
const final = await db
.select({ id: plants.id, commonName: plants.commonName, imageUrl: plants.imageUrl })
.from(plants)
.all();
const withImg = final.filter((p) => p.imageUrl);
const withoutImg = final.filter((p) => !p.imageUrl);
console.log(`\n${"═".repeat(50)}`);
console.log(`📊 FINAL: ${final.length} plants`);
console.log(` With images: ${withImg.length}`);
console.log(` Missing images: ${withoutImg.length}`);
if (withoutImg.length > 0) {
console.log(`\n📝 Plants still needing images:`);
withoutImg.forEach((p) => console.log(`${p.id}: ${p.commonName}`));
// Save to file for reference
const reportPath = resolve(__dirname, ".plant-image-review-needed.md");
let report = "# Plant Images — Still Missing\n\n";
report += `Generated: ${new Date().toISOString()}\n\n`;
report += `## 🚫 Plants without images (${withoutImg.length})\n\n`;
for (const p of withoutImg) {
report += `- **${p.commonName}** (\`${p.id}\`)\n`;
}
writeFileSync(reportPath, report, "utf-8");
console.log(` 📝 Review report: ${reportPath}`);
} else {
console.log("\n✅ All plants now have images!");
}
rawClient.close();
closeDb();
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* fix-classifications.ts — Fix misclassified diseases in the DB.
*
* Fixes:
* 1. Diseases named with viral indicators (mosaic, mottle, ringspot, virus, etc.)
* that are incorrectly tagged as "fungal"
* 2. Other suspicious patterns
*
* Usage: cd apps/web && npx tsx scripts/fix-classifications.ts
*/
import { readFileSync } from "fs";
import { resolve } from "path";
// Manually load .env.development
const envPath = resolve(__dirname, "../.env.development");
try {
const env = readFileSync(envPath, "utf-8");
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
}
}
} catch {}
import { getDb, closeDb } from "../src/lib/db/index";
import { diseases } from "../src/lib/db/schema";
import { createClient } from "@libsql/client";
type AgentType = "fungal" | "bacterial" | "viral" | "environmental";
interface FixRule {
test: (name: string) => boolean;
correctAgent: AgentType;
reason: string;
}
const FIX_RULES: FixRule[] = [
// Diseases explicitly named as "virus" or "viral"
{
test: (name) => /\b(virus|viral|viroid)\b/i.test(name),
correctAgent: "viral",
reason: "Name explicitly indicates viral disease",
},
// Potexvirus, carlavirus, etc.
{
test: (name) =>
/\b(virus\b|potex|carla|tobamo|poty|cucumo|ilar|nepo|tymovirus|geminivir|tom bushy stunt)\b/i.test(
name,
),
correctAgent: "viral",
reason: "Recognized virus genus in name",
},
// "Mosaic" diseases (typically viral)
{
test: (name) => /\bmosaic\b/i.test(name),
correctAgent: "viral",
reason: "Mosaic symptoms are typically caused by viruses",
},
// "Mottle" diseases (typically viral)
{
test: (name) => /\bmottle\b/i.test(name),
correctAgent: "viral",
reason: "Mottle symptoms are typically caused by viruses",
},
// "Ringspot" diseases (typically viral)
{
test: (name) => /\bringspot\b/i.test(name),
correctAgent: "viral",
reason: "Ringspot symptoms are typically caused by viruses",
},
// "Leaf curl" (many are viral)
{
test: (name) => /\bleaf curl\b|\bleafroll\b|\bleaf-roll\b/i.test(name),
correctAgent: "viral",
reason: "Leaf curl/roll diseases are often viral",
},
// "Rosette" (often viral or phytoplasma)
{
test: (name) => /\brosette\b/i.test(name),
correctAgent: "viral",
reason: "Rosette diseases are typically viral or phytoplasma",
},
// "Yellows" (often phytoplasma/viral)
{
test: (name) => /\byellows\b/i.test(name) && !/\bpeach\b/i.test(name),
correctAgent: "viral",
reason: "Yellows diseases are typically phytoplasma or viral",
},
// "Stunt" / "Dwarf" (often viral)
{
test: (name) => /\b(stunt|dwarf(ism)?)\b/i.test(name),
correctAgent: "viral",
reason: "Stunting/dwarfing diseases are often viral",
},
// Explicit bacterial in name
{
test: (name) =>
/\bbacterial\b|\bbacterium\b|\berwinia\b|\bpseudomonas\b|\bxanthomonas\b|\bralstonia\b|\bclavibacter\b|\bstreptomyces\b|\bagrobacterium\b/i.test(
name,
),
correctAgent: "bacterial",
reason: "Name indicates bacterial disease",
},
// Environmental/abiotic indicators
{
test: (name) =>
/\b(deficiency|abiotic|environmental|injury|damage|stress|sunscald|sunburn|chilling|freeze|frost|wind|hail|nutrient|toxicity|snow\s+(mold|scald)|winter\s+(injury|rot|kill))\b/i.test(
name,
),
correctAgent: "environmental",
reason: "Name indicates abiotic/environmental cause",
},
];
async function main() {
console.log("🔍 Fixing disease classifications\n");
const db = getDb();
const allDiseases = await db
.select({ id: diseases.id, name: diseases.name, causalAgentType: diseases.causalAgentType })
.from(diseases)
.all();
console.log(`📋 ${allDiseases.length} total diseases\n`);
const rawClient = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_TOKEN!,
});
const updates: { id: string; newAgent: AgentType; rule: FixRule; oldAgent: string }[] = [];
for (const d of allDiseases) {
for (const rule of FIX_RULES) {
if (rule.test(d.name)) {
if (d.causalAgentType !== rule.correctAgent) {
updates.push({
id: d.id,
newAgent: rule.correctAgent,
rule,
oldAgent: d.causalAgentType,
});
}
break; // First matching rule wins
}
}
}
console.log(`Found ${updates.length} diseases needing reclassification:\n`);
// Group by correction type
const grouped: Record<string, { from: string; to: string; items: string[] }> = {};
for (const u of updates) {
const key = `${u.oldAgent}${u.newAgent}`;
if (!grouped[key]) grouped[key] = { from: u.oldAgent, to: u.newAgent, items: [] };
grouped[key].items.push(` ${u.id}`);
}
for (const [, g] of Object.entries(grouped)) {
console.log(`${g.from}${g.to} (${g.items.length} diseases):`);
g.items.slice(0, 10).forEach((l) => console.log(l));
if (g.items.length > 10) console.log(` ... and ${g.items.length - 10} more`);
console.log();
}
// Apply updates
if (updates.length === 0) {
console.log("✅ No corrections needed");
} else {
console.log(`Applying ${updates.length} corrections...\n`);
// Batch update in groups of 50
for (let i = 0; i < updates.length; i += 50) {
const batch = updates.slice(i, i + 50);
await rawClient.batch(
batch.map((u) => ({
sql: "UPDATE diseases SET causal_agent_type = ?, updated_at = datetime('now') WHERE id = ?",
args: [u.newAgent, u.id],
})),
"write",
);
process.stdout.write(` ${Math.min(i + 50, updates.length)}/${updates.length}\n`);
}
console.log(`\n✅ ${updates.length} diseases reclassified`);
}
// Print summary stats
const after = await db.select({ causalAgentType: diseases.causalAgentType }).from(diseases).all();
const counts: Record<string, number> = {};
after.forEach((d) => {
counts[d.causalAgentType] = (counts[d.causalAgentType] || 0) + 1;
});
console.log("\n📊 Updated distribution:");
for (const [type, count] of Object.entries(counts).sort()) {
console.log(` ${type}: ${count}`);
}
rawClient.close();
closeDb();
}
main().catch((err) => {
console.error("\n❌", err);
process.exit(1);
});

View File

@@ -20,7 +20,7 @@ import { getDb, closeDb } from "../src/lib/db/index";
import { diseases, plants } from "../src/lib/db/schema";
import PLANTS from "./plant-list";
import { GENERIC_TEMPLATES, getTemplatesForFamily, slugify } from "./disease-templates";
import type { Disease, CausalAgentType, Severity } from "../src/lib/types";
import type { CausalAgentType, Prevalence, Severity } from "../src/lib/types";
interface DiseaseEntry {
id: string;
@@ -35,6 +35,7 @@ interface DiseaseEntry {
prevention: string[];
lookalikeIds: string[];
severity: Severity;
prevalence: Prevalence;
sourceUrl: string;
}
@@ -92,7 +93,6 @@ async function main() {
// Determine how many diseases we need for this plant
const targetMin = 15; // minimum diseases per plant
const targetMax = 45; // maximum diseases per plant
// Get family-specific templates
const familyTemplates = getTemplatesForFamily(plant.fam);
@@ -128,6 +128,7 @@ async function main() {
prevention: tmpl.prevention,
lookalikeIds: [],
severity: tmpl.severity,
prevalence: tmpl.severity === "critical" ? "uncommon" : "common",
sourceUrl: "https://pddc.wisc.edu/ (UW-Madison PDDC extension factsheets)",
});
}
@@ -202,7 +203,7 @@ async function main() {
for (let i = 0; i < toInsert.length; i += BATCH) {
const chunk = toInsert.slice(i, i + BATCH);
const stmts = chunk.map((d) => ({
sql: `INSERT OR IGNORE INTO diseases (id, plant_id, name, scientific_name, causal_agent_type, description, symptoms, causes, treatment, prevention, lookalike_ids, severity, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sql: `INSERT OR IGNORE INTO diseases (id, plant_id, name, scientific_name, causal_agent_type, description, symptoms, causes, treatment, prevention, lookalike_ids, severity, prevalence, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
d.id,
d.plantId,
@@ -216,6 +217,7 @@ async function main() {
JSON.stringify(d.prevention),
JSON.stringify(d.lookalikeIds),
d.severity,
d.prevalence ?? "uncommon",
d.sourceUrl,
],
}));

View File

@@ -68,6 +68,7 @@ async function main() {
prevention: d.prevention,
lookalikeIds: d.lookalikeDiseaseIds,
severity: d.severity,
prevalence: d.prevalence ?? "uncommon",
sourceUrl: "",
})
.onConflictDoNothing();

View File

@@ -31,13 +31,13 @@ const IMAGENET_MEAN = [0.485, 0.456, 0.406] as const;
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
/** Model input size */
const MODEL_SIZE = 224;
const MODEL_SIZE = 160;
// ─── Server-side image preprocessing ─────────────────────────────────────────
/**
* Load an uploaded image and preprocess it into a Float32Array tensor
* with shape [3, 224, 224] (NCHW without batch dim) using ImageNet normalization.
* with shape [3, 160, 160] (NCHW without batch dim) using ImageNet normalization.
*
* @param imageId - The image ID from the upload endpoint
* @returns Float32Array tensor ready for inference

View File

@@ -0,0 +1,38 @@
/**
* POST /api/plants/[id]/view
*
* Increments the view count for a plant in the plant_views table.
* Called client-side from the plant detail page via a tiny tracker component.
*/
import { NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plantViews } from "@/lib/db/schema";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing plant id" }, { status: 400 });
}
try {
const db = getDb();
// Upsert: increment view_count if row exists, otherwise insert with count 1
await db
.insert(plantViews)
.values({ plantId: id, viewCount: 1 })
.onConflictDoUpdate({
target: plantViews.plantId,
set: { viewCount: sql`${plantViews.viewCount} + 1` },
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[View] Failed to record view for", id, err);
// Swallow errors — tracking failure shouldn't break the page
return NextResponse.json({ ok: true });
}
}

View File

@@ -37,6 +37,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/tomato.jpg",
diseaseCount: 15,
},
{
@@ -45,6 +46,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Ocimum basilicum",
family: "Lamiaceae",
category: "herb",
imageUrl: "https://example.com/basil.jpg",
diseaseCount: 3,
},
{
@@ -53,6 +55,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Rosa spp.",
family: "Rosaceae",
category: "flower",
imageUrl: "https://example.com/rose.jpg",
diseaseCount: 7,
},
{
@@ -61,6 +64,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Monstera deliciosa",
family: "Araceae",
category: "houseplant",
imageUrl: "https://example.com/monstera.jpg",
diseaseCount: 5,
},
{
@@ -69,6 +73,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Dracaena trifasciata",
family: "Asparagaceae",
category: "houseplant",
imageUrl: "https://example.com/snake-plant.jpg",
diseaseCount: 2,
},
{
@@ -77,6 +82,7 @@ const MOCK_PLANTS: PlantCardData[] = [
scientificName: "Capsicum annuum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/pepper.jpg",
diseaseCount: 9,
},
];

View File

@@ -7,6 +7,14 @@ import EmptyState from "@/components/EmptyState";
import { PLANT_CATEGORIES } from "@/lib/constants";
import type { PlantCardData } from "@/components/PlantCard";
type SortKey = "name" | "recent" | "popular";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "name", label: "Name (A-Z)" },
{ value: "recent", label: "Recently Updated" },
{ value: "popular", label: "Most Popular" },
];
interface BrowseContentProps {
allPlants: PlantCardData[];
}
@@ -24,6 +32,7 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [activeCategory, setActiveCategory] = useState<Category>("all");
const [sortKey, setSortKey] = useState<SortKey>("name");
const filteredPlants = useMemo(() => {
let result = allPlants;
@@ -42,8 +51,22 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
);
}
return result;
}, [activeCategory, searchQuery, allPlants]);
// Sort
const sorted = [...result];
if (sortKey === "recent") {
sorted.sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bTime - aTime; // newest first
});
} else if (sortKey === "popular") {
sorted.sort((a, b) => (b.viewCount ?? 0) - (a.viewCount ?? 0));
} else {
sorted.sort((a, b) => a.commonName.localeCompare(b.commonName));
}
return sorted;
}, [activeCategory, searchQuery, allPlants, sortKey]);
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
@@ -55,44 +78,14 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
</p>
</div>
{/* Search bar */}
<div className="relative mb-6">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
{/* Controls row: search + sort */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Search bar */}
<div className="relative flex-1">
<label htmlFor="browse-search" className="sr-only">
Search plants and diseases
</label>
<div className="relative">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
@@ -103,13 +96,80 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
)}
<input
id="browse-search"
type="search"
placeholder="Search by plant name, scientific name, or family..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 pl-10 pr-4 py-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm"
/>
</div>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors"
aria-label="Clear search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Sort dropdown */}
<div className="relative shrink-0">
<label htmlFor="sort-select" className="sr-only">
Sort by
</label>
<select
id="sort-select"
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="w-full sm:w-auto appearance-none rounded-xl border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 px-4 py-3 pr-10 text-sm text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-all shadow-sm cursor-pointer"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</div>
{/* Category filter chips */}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
import { useState, useCallback, useMemo } from "react";
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
import ImageLightbox from "@/components/ImageLightbox";
// ─── Severity badge ───
@@ -79,6 +79,7 @@ function DiseaseCard({
)}
</div>
<div className="flex flex-wrap gap-2">
<PrevalenceBadge prevalence={disease.prevalence} />
<TypeBadge type={disease.causalAgentType} />
<SeverityBadge severity={disease.severity} />
</div>
@@ -207,16 +208,205 @@ function DiseaseCard({
);
}
// ─── Prevalence badge ───
function PrevalenceBadge({ prevalence }: { prevalence: Prevalence }) {
const icons: Record<Prevalence, string> = {
common: "📊",
uncommon: "📋",
rare: "📌",
};
const colors: Record<Prevalence, string> = {
common: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
uncommon: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300",
rare: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[prevalence]}`}
>
{icons[prevalence]} {prevalence.charAt(0).toUpperCase() + prevalence.slice(1)}
</span>
);
}
// ─── Sort / Search controls ───
const SEVERITY_RANK: Record<Severity, number> = {
critical: 4,
high: 3,
moderate: 2,
low: 1,
};
const PREVALENCE_RANK: Record<Prevalence, number> = {
common: 3,
uncommon: 2,
rare: 1,
};
type SortField = "prevalence" | "danger";
function SearchSortBar({
searchQuery,
onSearchChange,
sortField,
onSortFieldChange,
sortOrder,
onSortOrderToggle,
resultCount,
}: {
searchQuery: string;
onSearchChange: (q: string) => void;
sortField: SortField;
onSortFieldChange: (f: SortField) => void;
sortOrder: "asc" | "desc";
onSortOrderToggle: () => void;
resultCount: number;
}) {
return (
<div className="mb-6 space-y-4">
{/* Search */}
<div className="relative">
<span
className="absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400 dark:text-zinc-500 pointer-events-none"
aria-hidden="true"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"
/>
</svg>
</span>
<input
type="search"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search diseases by name…"
className="w-full rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 py-2 pl-10 pr-3 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:border-leaf-green-500 transition-colors"
aria-label="Search diseases"
/>
</div>
{/* Sort controls */}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-zinc-500 dark:text-zinc-400 font-medium">Sort by:</span>
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600 overflow-hidden">
<button
type="button"
onClick={() => onSortFieldChange("prevalence")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "prevalence"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Prevalence
</button>
<button
type="button"
onClick={() => onSortFieldChange("danger")}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
sortField === "danger"
? "bg-leaf-green-600 text-white"
: "bg-white dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
>
Danger
</button>
</div>
{/* Direction toggle */}
<button
type="button"
onClick={onSortOrderToggle}
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
aria-label={
sortOrder === "desc"
? "Sorted descending, click for ascending"
: "Sorted ascending, click for descending"
}
>
<svg
className={`h-3.5 w-3.5 transition-transform ${sortOrder === "asc" ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{sortField === "danger"
? sortOrder === "desc"
? "Most dangerous first"
: "Least dangerous first"
: sortOrder === "desc"
? "Most prevalent first"
: "Least prevalent first"}
</button>
<span className="text-xs text-zinc-400 dark:text-zinc-500 ml-auto">
{resultCount} {resultCount === 1 ? "result" : "results"}
</span>
</div>
</div>
);
}
// ─── Client component wrapper ───
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("danger");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Build list of images from diseases that have imageUrls
const images = diseases
.filter((d) => d.imageUrl)
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` }));
// ── Filtered + sorted diseases ──
const processed = useMemo(() => {
// Filter
let result = diseases;
const trimmed = searchQuery.trim().toLowerCase();
if (trimmed) {
result = result.filter(
(d) =>
d.name.toLowerCase().includes(trimmed) ||
d.scientificName.toLowerCase().includes(trimmed),
);
}
// Sort
const sorted = [...result].sort((a, b) => {
let cmp: number;
if (sortField === "danger") {
cmp = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
} else {
cmp = PREVALENCE_RANK[a.prevalence] - PREVALENCE_RANK[b.prevalence];
}
return sortOrder === "desc" ? -cmp : cmp;
});
return sorted;
}, [diseases, searchQuery, sortField, sortOrder]);
// Build list of images from processed diseases that have imageUrls
const images = useMemo(
() =>
processed
.filter((d) => d.imageUrl)
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` })),
[processed],
);
const handleImageClick = useCallback(
(disease: Disease) => {
@@ -229,15 +419,40 @@ export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
const handleClose = useCallback(() => setLightboxOpen(false), []);
const handleSortOrderToggle = useCallback(() => {
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
if (diseases.length === 0) return null;
return (
<>
<div className="space-y-6">
{diseases.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
))}
</div>
<SearchSortBar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortField={sortField}
onSortFieldChange={setSortField}
sortOrder={sortOrder}
onSortOrderToggle={handleSortOrderToggle}
resultCount={processed.length}
/>
{processed.length > 0 ? (
<div className="space-y-6">
{processed.map((disease) => (
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🔍
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
No diseases match &ldquo;{searchQuery}&rdquo;.
</p>
</div>
)}
{lightboxOpen && images.length > 0 && (
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />

View File

@@ -1,9 +1,11 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
import { getEmojiForCategory, getPlantDescription } from "@/lib/display-helpers";
import { getPlantDescription } from "@/lib/display-helpers";
import DiseaseCards from "./DiseaseCards";
import PlantViewTracker from "@/components/PlantViewTracker";
interface Props {
params: Promise<{ plantId: string }>;
@@ -44,7 +46,6 @@ export default async function PlantDetailPage({ params }: Props) {
}
const { plant, diseases } = result;
const emoji = getEmojiForCategory(plant.category);
const description = getPlantDescription(
plant.commonName,
plant.scientificName,
@@ -53,107 +54,135 @@ export default async function PlantDetailPage({ params }: Props) {
);
return (
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link
href="/"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Home
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link
href="/browse"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Browse
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
</ol>
</nav>
<>
<PlantViewTracker plantId={plantId} />
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* Breadcrumb */}
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<li>
<Link
href="/"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Home
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link
href="/browse"
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
>
Browse
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
</ol>
</nav>
{/* Plant hero */}
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
{/* Emoji illustration */}
<div className="flex items-center justify-center h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
{emoji}
</span>
</div>
<div className="flex-1 min-w-0">
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
{plant.commonName}
</h1>
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
{plant.scientificName}
</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Family: <span className="font-medium">{plant.family}</span>
{" · "}
Category: <span className="font-medium capitalize">{plant.category}</span>
</p>
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{description}
</p>
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">💚</span>
<span>{plant.careSummary}</span>
{/* Plant hero */}
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
{/* Plant image */}
<div className="relative h-32 w-32 sm:h-40 sm:w-40 shrink-0 rounded-2xl overflow-hidden bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
{plant.imageUrl ? (
<Image
src={plant.imageUrl}
alt={plant.commonName}
fill
className="object-cover"
sizes="(min-width: 640px) 16rem, 8rem"
unoptimized
/>
) : (
<div className="flex items-center justify-center w-full h-full">
<svg
className="w-12 h-12 text-leaf-green-300 dark:text-leaf-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
</svg>
</div>
)}
</div>
</div>
</div>
{/* Identify disease CTA */}
<div className="mb-10 rounded-xl bg-gradient-to-r from-leaf-green-50 to-soil-brown-50 dark:from-leaf-green-950 dark:to-soil-brown-950 border border-leaf-green-200 dark:border-leaf-green-800 p-5 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
Upload a photo for AI-powered disease identification.
<div className="flex-1 min-w-0">
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
{plant.commonName}
</h1>
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
{plant.scientificName}
</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Family: <span className="font-medium">{plant.family}</span>
{" · "}
Category: <span className="font-medium capitalize">{plant.category}</span>
</p>
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
{description}
</p>
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-hidden="true">💚</span>
<span>{plant.careSummary}</span>
</div>
</div>
<Link
href="/upload"
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
📸 Identify a Disease
</Link>
</div>
{/* Identify disease CTA */}
<div className="mb-10 rounded-xl bg-gradient-to-r from-leaf-green-50 to-soil-brown-50 dark:from-leaf-green-950 dark:to-soil-brown-950 border border-leaf-green-200 dark:border-leaf-green-800 p-5 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
Upload a photo for AI-powered disease identification.
</p>
</div>
<Link
href="/upload"
className="inline-flex items-center gap-2 shrink-0 rounded-lg bg-leaf-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-leaf-green-700 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
📸 Identify a Disease
</Link>
</div>
</div>
{/* Disease list */}
<div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
Known Diseases
</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{diseases.length > 0 ? (
<DiseaseCards diseases={diseases} />
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🌿
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
Disease data for {plant.commonName} is being researched and will be added soon.
</p>
</div>
)}
</div>
</div>
{/* Disease list */}
<div>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
Known Diseases
</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
{diseases.length === 0
? "No diseases currently documented for this plant."
: `${diseases.length} ${diseases.length === 1 ? "disease" : "diseases"} documented for ${plant.commonName}.`}
</p>
{diseases.length > 0 ? (
<DiseaseCards diseases={diseases} />
) : (
<div className="rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 p-10 text-center">
<span className="text-4xl block mb-3" aria-hidden="true">
🌿
</span>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">
Disease data for {plant.commonName} is being researched and will be added soon.
</p>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -10,6 +10,7 @@ describe("PlantCard", () => {
scientificName: "Solanum lycopersicum",
family: "Solanaceae",
category: "vegetable",
imageUrl: "https://example.com/tomato.jpg",
diseaseCount: 2,
};
@@ -18,10 +19,18 @@ describe("PlantCard", () => {
expect(screen.getByText("Tomato")).toBeInTheDocument();
});
it("renders plant emoji (generated from category)", () => {
it("renders plant image", () => {
render(<PlantCard plant={mockPlant} />);
// Vegetable category → 🥬 emoji
expect(screen.getByText("🥬")).toBeInTheDocument();
const img = screen.getByRole("img") as HTMLImageElement;
expect(img).toHaveAttribute("src", expect.stringContaining("tomato.jpg"));
expect(img).toHaveAttribute("alt", "Tomato");
});
it("renders fallback SVG when no image URL", () => {
const noImagePlant = { ...mockPlant, imageUrl: "" };
render(<PlantCard plant={noImagePlant} />);
// Should render SVG fallback instead of image
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("renders plant family", () => {

View File

@@ -1,6 +1,5 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { getEmojiForCategory } from "@/lib/display-helpers";
export interface PlantCardData {
id: string;
@@ -8,7 +7,10 @@ export interface PlantCardData {
scientificName: string;
family: string;
category: string;
imageUrl: string;
diseaseCount: number;
updatedAt?: string;
viewCount?: number;
}
interface PlantCardProps {
@@ -17,26 +19,45 @@ interface PlantCardProps {
}
/**
* Plant card showing emoji, name, family, and optional disease count.
* Plant card showing image, name, family, and optional disease count.
* Used on the homepage featured section and browse grid.
*/
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
const emoji = getEmojiForCategory(plant.category);
return (
<Link
href={`/browse/${plant.id}`}
className="group block rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm hover:shadow-md transition-all duration-200 hover:-translate-y-1 focus:outline-none focus:ring-2 focus:ring-leaf-green-500 focus:ring-offset-2"
>
{/* Emoji illustration area */}
<div className="flex items-center justify-center h-40 bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900">
<span
className="text-6xl transition-transform duration-300 group-hover:scale-110"
role="img"
aria-hidden="true"
>
{emoji}
</span>
{/* Plant image */}
<div className="relative h-40 bg-gradient-to-br from-leaf-green-50 to-leaf-green-100 dark:from-leaf-green-950 dark:to-leaf-green-900 overflow-hidden">
{plant.imageUrl ? (
<Image
src={plant.imageUrl}
alt={plant.commonName}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
unoptimized
/>
) : (
<div className="flex items-center justify-center w-full h-full">
<svg
className="w-16 h-16 text-leaf-green-300 dark:text-leaf-green-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c-1.5 2-4 4-4 7a4 4 0 0 0 8 0c0-3-2.5-5-4-7Z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-9" />
</svg>
</div>
)}
</div>
<div className="p-4">

View File

@@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
/**
* Tracks a plant page view by POSTing to the view-count API.
* Renders nothing — purely a side-effect component.
*/
export default function PlantViewTracker({ plantId }: { plantId: string }) {
useEffect(() => {
const controller = new AbortController();
fetch(`/api/plants/${encodeURIComponent(plantId)}/view`, {
method: "POST",
signal: controller.signal,
// Keepalive so the request completes even if the user navigates away quickly
keepalive: true,
}).catch(() => {
// Silently ignore tracking failures
});
return () => controller.abort();
}, [plantId]);
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
import { sql, eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { plants, diseases } from "@/lib/db/schema";
import { plants, diseases, plantViews } from "@/lib/db/schema";
import type { PlantCardData } from "@/components/PlantCard";
export type { PlantCardData };
@@ -24,10 +24,14 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
imageUrl: plants.imageUrl,
updatedAt: plants.updatedAt,
viewCount: sql<number>`COALESCE(${plantViews.viewCount}, 0)`,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)
.leftJoin(diseases, eq(diseases.plantId, plants.id))
.leftJoin(plantViews, eq(plantViews.plantId, plants.id))
.groupBy(plants.id)
.orderBy(plants.commonName);
@@ -37,6 +41,9 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
scientificName: r.scientificName,
family: r.family,
category: r.category,
imageUrl: r.imageUrl,
updatedAt: r.updatedAt,
viewCount: r.viewCount,
diseaseCount: r.diseaseCount,
}));
}
@@ -53,6 +60,7 @@ export async function getBrowsePlant(id: string): Promise<PlantCardData | null>
scientificName: plants.scientificName,
family: plants.family,
category: plants.category,
imageUrl: plants.imageUrl,
diseaseCount: sql<number>`COUNT(${diseases.id})`,
})
.from(plants)

View File

@@ -18,6 +18,7 @@ import type {
Plant,
PlantListParams,
PlantWithDiseases,
Prevalence,
Severity,
PlantCategory,
} from "@/lib/types";
@@ -50,6 +51,7 @@ function toDisease(row: typeof diseases.$inferSelect): Disease {
prevention: row.prevention as string[],
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
severity: row.severity as Severity,
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
imageUrl: (row.imageUrl as string) || undefined,
};
}
@@ -278,6 +280,7 @@ export async function validateKnowledgeBase(): Promise<string[]> {
"environmental",
];
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare"];
const db = getDb();
@@ -328,6 +331,11 @@ export async function validateKnowledgeBase(): Promise<string[]> {
errors.push(`Disease "${d.id}" has invalid severity: ${full.severity}`);
}
// Valid prevalence
if (full.prevalence && !validPrevalences.includes(full.prevalence as Prevalence)) {
errors.push(`Disease "${d.id}" has invalid prevalence: ${full.prevalence}`);
}
// Minimum counts
const symptoms = full.symptoms as string[];
const causes = full.causes as string[];

View File

@@ -10,7 +10,7 @@
*/
import { createClient, type InValue } from "@libsql/client";
import type { Plant, Disease, CausalAgentType, Severity } from "./types";
import type { Plant, Disease, CausalAgentType, Prevalence, Severity } from "./types";
// ─── Client ──────────────────────────────────────────────────────────────────
@@ -173,6 +173,7 @@ export function rowToDisease(row: Record<string, unknown>): Disease {
prevention: JSON.parse(row.prevention as string) as string[],
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
severity: row.severity as Severity,
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
};
}

View File

@@ -54,6 +54,11 @@ export const diseases = sqliteTable(
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
prevention: text("prevention", { mode: "json" }).notNull().default([]).$type<string[]>(),
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
prevalence: text("prevalence", {
enum: ["common", "uncommon", "rare"],
})
.notNull()
.default("uncommon"),
severity: text("severity", {
enum: ["low", "moderate", "high", "critical"],
}).notNull(),
@@ -70,6 +75,7 @@ export const diseases = sqliteTable(
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
severityIdx: index("idx_diseases_severity").on(table.severity),
prevalenceIdx: index("idx_diseases_prevalence").on(table.prevalence),
}),
);
@@ -92,6 +98,21 @@ export const scrapeSources = sqliteTable("scrape_sources", {
.default(sql`(datetime('now'))`),
});
// ─── Plant Views Table ───────────────────────────────────────────────────────
export const plantViews = sqliteTable(
"plant_views",
{
plantId: text("plant_id")
.primaryKey()
.references(() => plants.id),
viewCount: integer("view_count").notNull().default(0),
},
(table) => ({
viewCountIdx: index("idx_plant_views_count").on(table.viewCount),
}),
);
// ─── Relation Inference ──────────────────────────────────────────────────────
export const plantsRelations = {};

View File

@@ -1,21 +1,21 @@
/**
* Client-side image preprocessing pipeline.
*
* Resizes images to model-expected dimensions (224×224 by default),
* Resizes images to model-expected dimensions (160×160 by default),
* converts RGBA → RGB, normalizes pixel values, and produces flat
* Float32Array tensors ready for ML inference or base64 transmission.
*
* Tensor shape: [1, 3, 224, 224] — NCHW layout matching MobileNet / ResNet.
* Tensor shape: [1, 3, 160, 160] — NCHW layout matching MobileNetV2.
*
* Configurable via env:
* IMAGE_MODEL_SIZE — target dimension (default 224)
* IMAGE_MODEL_SIZE — target dimension (default 160)
* IMAGE_MEAN_R/G/B — per-channel mean for normalization (default 0.485, 0.456, 0.406 — ImageNet)
* IMAGE_STD_R/G/B — per-channel std for normalization (default 0.229, 0.224, 0.225 — ImageNet)
*/
// ─── Configuration ───────────────────────────────────────────────────────────
const DEFAULT_MODEL_SIZE = 224;
const DEFAULT_MODEL_SIZE = 160;
const DEFAULT_MEAN = [0.485, 0.456, 0.406] as const; // ImageNet RGB means
const DEFAULT_STD = [0.229, 0.224, 0.225] as const; // ImageNet RGB stds

View File

@@ -130,12 +130,12 @@ describe("createRandomTensor", () => {
});
describe("INPUT_SHAPE and INPUT_SIZE", () => {
it("INPUT_SHAPE is [1, 3, 224, 224]", () => {
expect(INPUT_SHAPE).toEqual([1, 3, 224, 224]);
it("INPUT_SHAPE is [1, 3, 160, 160]", () => {
expect(INPUT_SHAPE).toEqual([1, 3, 160, 160]);
});
it("INPUT_SIZE equals 3 * 224 * 224", () => {
expect(INPUT_SIZE).toBe(3 * 224 * 224);
it("INPUT_SIZE equals 3 * 160 * 160", () => {
expect(INPUT_SIZE).toBe(3 * 160 * 160);
});
it("DEFAULT_TOP_K is 5", () => {

View File

@@ -15,18 +15,18 @@ import { softmaxFloat32, getTopKFloat32 } from "./confidence";
/** Number of top predictions to return */
export const DEFAULT_TOP_K = 5;
/** Input tensor shape: [batch=1, channels=3, height=224, width=224] */
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 224, 224];
/** Input tensor shape: [batch=1, channels=3, height=160, width=160] */
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 160, 160];
/** Expected input tensor length */
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 224 * 224 = 150528
export const INPUT_SIZE = INPUT_SHAPE[1] * INPUT_SHAPE[2] * INPUT_SHAPE[3]; // 3 * 160 * 160 = 76800
// ─── Main Inference ──────────────────────────────────────────────────────────
/**
* Run the full inference pipeline on a preprocessed image tensor.
*
* @param imageTensor - Normalized Float32Array of shape [1, 3, 224, 224] (NCHW)
* @param imageTensor - Normalized Float32Array of shape [1, 3, 160, 160] (NCHW)
* @param topK - Number of top predictions to return (default 5)
* @returns InferenceResult with top-K predictions and timing
*/

View File

@@ -196,8 +196,8 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
async predict(tensor: Float32Array): Promise<ModelOutput> {
const startTime = performance.now();
// Reshape to [1, 3, 224, 224] NCHW → [1, 224, 224, 3] NHWC for TF.js
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 224, 224])
// Reshape to [1, 3, 160, 160] NCHW → [1, 160, 160, 3] NHWC for TF.js
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 160, 160])
.transpose([1, 2, 0])
.expandDims(0);
@@ -220,7 +220,7 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
loaded: true,
backend: "tfjs",
modelId: MODEL_ID,
numClasses: 95, // Will be updated after model loads
numClasses: 38, // Original PlantVillage model
};
},
};
@@ -256,8 +256,8 @@ async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
async predict(tensor: Float32Array): Promise<ModelOutput> {
const startTime = performance.now();
// ONNX expects NCHW format: [1, 3, 224, 224]
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 224, 224]);
// ONNX expects NCHW format: [1, 3, 160, 160]
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 160, 160]);
const feeds = { [session.inputNames[0]]: inputTensor };
const results = await session.run(feeds);
@@ -278,7 +278,7 @@ async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
loaded: true,
backend: "onnx",
modelId: MODEL_ID,
numClasses: 95,
numClasses: 38,
};
},
};
@@ -313,7 +313,7 @@ function createMockModel(): PlantDiseaseModel {
loaded: false,
backend: "mock",
modelId: MODEL_ID,
numClasses: 95,
numClasses: 38,
error: "Model files not found. Running in demo mode with mock predictions.",
};
},
@@ -326,7 +326,7 @@ function createMockModel(): PlantDiseaseModel {
* reproducible but varied predictions.
*/
function generateMockLogits(tensor: Float32Array): Float32Array {
const numClasses = 95;
const numClasses = 38;
const logits = new Float32Array(numClasses);
// Simple hash of input for deterministic output

View File

@@ -9,6 +9,9 @@ export type CausalAgentType = "fungal" | "bacterial" | "viral" | "environmental"
/** Severity levels for plant diseases */
export type Severity = "low" | "moderate" | "high" | "critical";
/** How common/prevalent a disease is in the field */
export type Prevalence = "common" | "uncommon" | "rare";
/** Plant category for grouping and filtering */
export type PlantCategory =
| "vegetable"
@@ -69,6 +72,8 @@ export interface Disease {
lookalikeDiseaseIds: string[];
/** Overall severity of the disease */
severity: Severity;
/** How common/prevalent this disease is */
prevalence: Prevalence;
/** URL to a representative image showing disease symptoms */
imageUrl?: string;
}

View File

@@ -0,0 +1,152 @@
# 01. PlantVillage Class Inventory and Knowledge Base Mapping
meta:
id: production-ml-pipeline-01
feature: production-ml-pipeline
priority: P0
depends_on: []
tags: [data, mapping, research]
objective:
- Document all 38 PlantVillage model output classes
- Map each class index to a definitive disease ID in the knowledge base
- Identify which plants and diseases are missing from the KB and must be added
- Produce a complete, authoritative mapping file that subsequent tasks consume
deliverables:
- `src/lib/ml/plantvillage-classes.ts` — definitive mapping of all 38 class indices to structured metadata
- Updated `tasks/production-ml-pipeline/class-mapping-reference.md` — human-readable reference document
steps:
1. Document the canonical 38 PlantVillage class labels in order (index 037):
```
0: Apple___Apple_scab
1: Apple___Black_rot
2: Apple___Cedar_apple_rust
3: Apple___healthy
4: Blueberry___healthy
5: Cherry_(including_sour)___Powdery_mildew
6: Cherry_(including_sour)___healthy
7: Corn_(maize)___Cercospora_leaf_spot Gray_leaf_spot
8: Corn_(maize)___Common_rust_
9: Corn_(maize)___Northern_Leaf_Blight
10: Corn_(maize)___healthy
11: Grape___Black_rot
12: Grape___Esca_(Black_Measles)
13: Grape___Leaf_blight_(Isariopsis_Leaf_Spot)
14: Grape___healthy
15: Orange___Haunglongbing_(Citrus_greening)
16: Peach___Bacterial_spot
17: Peach___healthy
18: Pepper,_bell___Bacterial_spot
19: Pepper,_bell___healthy
20: Potato___Early_blight
21: Potato___Late_blight
22: Potato___healthy
23: Raspberry___healthy
24: Soybean___healthy
25: Squash___Powdery_mildew
26: Strawberry___Leaf_scorch
27: Strawberry___healthy
28: Tomato___Bacterial_spot
29: Tomato___Early_blight
30: Tomato___Late_blight
31: Tomato___Leaf_Mold
32: Tomato___Septoria_leaf_spot
33: Tomato___Spider_mites Two-spotted_spider_mite
34: Tomato___Target_Spot
35: Tomato___Tomato_Yellow_Leaf_Curl_Virus
36: Tomato___Tomato_mosaic_virus
37: Tomato___healthy
```
2. For each class, determine the mapping target:
- **Healthy classes** (13 total: indices 3, 4, 6, 10, 14, 17, 19, 22, 23, 24, 27, 37): map to a special `"healthy"` sentinel. These indicate the model detected no disease.
- **Disease classes with exact KB match**: map directly to existing disease ID.
- 28 → `bacterial-leaf-spot-tomato` (Tomato Bacterial_spot ≈ bacterial-leaf-spot-tomato)
- 29 → `early-blight`
- 30 → `late-blight`
- 32 → `septoria-leaf-spot`
- 25 → `squash-powdery-mildew`
- 26 → `strawberry-leaf-scorch`
- 18 → `pepper-bacterial-wilt` (closest match to Pepper Bacterial_spot)
- **Disease classes needing new KB entries** (no existing disease in our KB):
- 0: Apple_scab → new disease `apple-scab` under plant `apple`
- 1: Apple_black_rot → new disease `apple-black-rot` under plant `apple`
- 2: Apple_cedar_apple_rust → new disease `apple-cedar-apple-rust` under plant `apple`
- 5: Cherry_powdery_mildew → new disease `cherry-powdery-mildew` under plant `cherry`
- 7: Corn_cercospora_leaf_spot → new disease `corn-gray-leaf-spot` under plant `corn`
- 8: Corn_common_rust → new disease `corn-common-rust` under plant `corn`
- 9: Corn_northern_leaf_blight → new disease `corn-northern-leaf-blight` under plant `corn`
- 11: Grape_black_rot → new disease `grape-black-rot` under plant `grape`
- 12: Grape_esca → new disease `grape-esca` under plant `grape`
- 13: Grape_leaf_blight → new disease `grape-leaf-blight` under plant `grape`
- 15: Orange_huanglongbing → new disease `orange-citrus-greening` under plant `orange`
- 16: Peach_bacterial_spot → new disease `peach-bacterial-spot` under plant `peach`
- 20: Potato_early_blight → new disease `potato-early-blight` under plant `potato`
- 21: Potato_late_blight → new disease `potato-late-blight` under plant `potato`
- 31: Tomato_leaf_mold → new disease `tomato-leaf-mold` under plant `tomato`
- 33: Tomato_spider_mites → new disease `tomato-spider-mites` under plant `tomato`
- 34: Tomato_target_spot → new disease `tomato-target-spot` under plant `tomato`
- 35: Tomato_yellow_leaf_curl_virus → new disease `tomato-yellow-leaf-curl-virus` under plant `tomato`
- 36: Tomato_mosaic_virus → new disease `tomato-mosaic-virus` under plant `tomato`
3. Create the mapping type and data structure in `src/lib/ml/plantvillage-classes.ts`:
```typescript
export interface PlantVillageClass {
index: number;
rawLabel: string;
plantId: string; // KB plant slug
diseaseId: string | null; // null for healthy classes
isHealthy: boolean;
displayName: string; // human-readable disease name
}
export const PLANTVILLAGE_CLASSES: readonly PlantVillageClass[] = [ ... ];
```
4. For each class, also record:
- The PlantVillage plant name (e.g., "Tomato", "Apple")
- The target KB plantId (e.g., "tomato", "apple")
- The target KB diseaseId (e.g., "early-blight") or null for healthy
- Whether the disease needs to be added to the KB (boolean flag for task 02)
5. Verify the mapping covers all 38 indices with no gaps or duplicates.
tests:
- Unit: mapping has exactly 38 entries
- Unit: indices 037 are all present, no gaps
- Unit: each non-healthy entry has a non-null diseaseId
- Unit: each healthy entry has null diseaseId and isHealthy=true
- Unit: no duplicate diseaseIds across non-healthy entries
- Unit: all plantIds are valid slugs (lowercase, kebab-case)
acceptance_criteria:
- `src/lib/ml/plantvillage-classes.ts` exports `PLANTVILLAGE_CLASSES` array with exactly 38 entries
- Every index 037 maps to exactly one entry
- 13 entries are healthy (isHealthy=true, diseaseId=null)
- 25 entries are diseases with valid plantId and diseaseId
- Each entry includes rawLabel, plantId, diseaseId, displayName
- All new disease IDs follow kebab-case convention matching existing KB pattern
- Reference document `class-mapping-reference.md` lists all 38 classes with their KB mappings
validation:
- `npx vitest run src/lib/ml/plantvillage-classes.test.ts` — all mapping tests pass
- Manual review: each of the 25 disease entries maps to a plausible disease in our KB
notes:
- This task produces the authoritative mapping consumed by task 02 (KB expansion) and task 03 (label mapping)
- The PlantVillage class order is fixed by the model's training — do NOT reorder
- "Tomato Bacterial_spot" maps to our existing `bacterial-leaf-spot-tomato` — this is the closest match, not a perfect one
- "Pepper Bacterial_spot" maps to `pepper-bacterial-wilt` — imperfect but closest available match
- 10 new plants must be added to the KB: apple, blueberry, cherry, corn, grape, orange, peach, potato, raspberry, soybean
- Blueberry, Raspberry, Soybean only have "healthy" class — still need plant entries for context but no new disease entries

View File

@@ -0,0 +1,149 @@
# 02. Label Mapping Layer Implementation
meta:
id: production-ml-pipeline-02
feature: production-ml-pipeline
priority: P0
depends_on: [production-ml-pipeline-01]
tags: [implementation, knowledge-base, tests-required]
objective:
- Expand the knowledge base to cover all PlantVillage plants and diseases
- Rewrite `src/lib/ml/labels.ts` to use the PlantVillage class mapping from task 01
- Ensure every model output index resolves to a valid KB disease or the "healthy" sentinel
- The label layer must be the single source of truth for model-index → disease mapping
deliverables:
- Updated `src/data/plants.json` — 10 new PlantVillage plants added (apple, blueberry, cherry, corn, grape, orange, peach, potato, raspberry, soybean)
- Updated `src/data/diseases.json` — 19 new disease entries added for PlantVillage diseases not yet in KB
- `src/lib/ml/labels.ts` — fully rewritten to use PlantVillage class mapping
- `src/lib/ml/labels.test.ts` — updated to validate against new mapping
- `scripts/seed-plantvillage-kb.ts` — DB migration script to insert new plants and diseases into Turso
steps:
1. **Add 10 new plants to `src/data/plants.json`** — each with proper metadata:
```typescript
// New plants needed (PlantVillage coverage):
{ id: "apple", commonName: "Apple", scientificName: "Malus domestica", family: "Rosaceae", category: "fruit" }
{ id: "cherry", commonName: "Cherry", scientificName: "Prunus avium", family: "Rosaceae", category: "fruit" }
{ id: "corn", commonName: "Corn (Maize)", scientificName: "Zea mays", family: "Poaceae", category: "vegetable" }
{ id: "grape", commonName: "Grape", scientificName: "Vitis vinifera", family: "Vitaceae", category: "fruit" }
{ id: "orange", commonName: "Orange", scientificName: "Citrus sinensis", family: "Rutaceae", category: "fruit" }
{ id: "peach", commonName: "Peach", scientificName: "Prunus persica", family: "Rosaceae", category: "fruit" }
{ id: "potato", commonName: "Potato", scientificName: "Solanum tuberosum", family: "Solanaceae", category: "vegetable" }
{ id: "blueberry", commonName: "Blueberry", scientificName: "Vaccinium corymbosum", family: "Ericaceae", category: "fruit" }
{ id: "raspberry", commonName: "Raspberry", scientificName: "Rubus idaeus", family: "Rosaceae", category: "fruit" }
{ id: "soybean", commonName: "Soybean", scientificName: "Glycine max", family: "Fabaceae", category: "vegetable" }
```
- Add `imageUrl` for each (use Wikipedia pageimages, same pattern as `fill-plant-images.ts`)
- Add `careSummary` for each
2. **Add 19 new diseases to `src/data/diseases.json`** — each with full structured data:
- Use the template-based approach from `scripts/disease-templates.ts` where possible
- Source disease details from:
- UW-Madison PDDC factsheets (pddc.wisc.edu)
- Cornell Plant Clinic (plantclinic.cornell.edu)
- University extension publications
- Each disease must have: `id`, `plantId`, `name`, `scientificName`, `causalAgentType`, `description`, `symptoms` (≥3), `causes` (≥2), `treatment` (≥3), `prevention` (≥2), `lookalikeDiseaseIds`, `severity`, `prevalence`
- New disease entries needed:
- apple-scab, apple-black-rot, apple-cedar-apple-rust (plant: apple)
- cherry-powdery-mildew (plant: cherry)
- corn-gray-leaf-spot, corn-common-rust, corn-northern-leaf-blight (plant: corn)
- grape-black-rot, grape-esca, grape-leaf-blight (plant: grape)
- orange-citrus-greening (plant: orange)
- peach-bacterial-spot (plant: peach)
- potato-early-blight, potato-late-blight (plant: potato)
- tomato-leaf-mold, tomato-spider-mites, tomato-target-spot, tomato-yellow-leaf-curl-virus, tomato-mosaic-virus (plant: tomato)
- Use programmatic approach: write a generator script that pulls from UW-Madison PDDC / Cornell factsheets and Wikipedia, following the same pattern as `scripts/generate-full-kb.ts`
3. **Update lookalikeDiseaseIds** — cross-reference within new diseases:
- Apple scab ↔ Apple black rot (both cause leaf spots on apple)
- Potato early blight ↔ Potato late blight (both affect potato foliage)
- Grape black rot ↔ Grape esca (both cause fruit rot)
- Tomato early blight ↔ Tomato septoria leaf spot ↔ Tomato target spot (all cause leaf lesions)
- Tomato leaf mold ↔ Tomato septoria leaf spot (both cause leaf spots in humid conditions)
4. **Rewrite `src/lib/ml/labels.ts`** to use the PlantVillage mapping:
```typescript
import { PLANTVILLAGE_CLASSES } from "./plantvillage-classes";
// Total output classes from model
export const NUM_CLASSES = 38;
// Index 037 → disease lookup
export function getDiseaseIdForIndex(index: number): string {
const entry = PLANTVILLAGE_CLASSES[index];
if (!entry || entry.isHealthy) return "healthy";
return entry.diseaseId;
}
export function getPlantIdForIndex(index: number): string {
return PLANTVILLAGE_CLASSES[index]?.plantId ?? "unknown";
}
export function isHealthyClass(index: number): boolean {
return PLANTVILLAGE_CLASSES[index]?.isHealthy ?? false;
}
// Disease ID → index (for reverse lookup)
export function getIndexForDiseaseId(diseaseId: string): number {
const entry = PLANTVILLAGE_CLASSES.find((c) => c.diseaseId === diseaseId.toLowerCase());
return entry?.index ?? -1;
}
```
5. **Remove old assumptions** — the old labels.ts assumed 95 classes (93 diseases + healthy + unknown). Delete all references to `diseases.json` index ordering from labels.ts. The mapping is now defined by `plantvillage-classes.ts`, not by JSON file order.
6. **Create DB migration script** `scripts/seed-plantvillage-kb.ts`:
- Read updated `src/data/plants.json` and `src/data/diseases.json`
- Insert new plants and diseases into Turso DB using Drizzle ORM
- Use UPSERT (INSERT OR REPLACE) to be idempotent
- Log what was inserted/updated
7. **Run the migration** to populate the DB with new data.
tests:
- Unit: `labels.test.ts` validates all 38 indices map correctly
- Unit: `getDiseaseIdForIndex(29)` returns `"early-blight"`
- Unit: `getDiseaseIdForIndex(3)` returns `"healthy"` (Apple healthy class)
- Unit: `getIndexForDiseaseId("early-blight")` returns `29`
- Unit: `isHealthyClass(37)` returns `true` (Tomato healthy)
- Unit: `isHealthyClass(29)` returns `false` (Tomato Early_blight)
- Unit: `getPlantIdForIndex(0)` returns `"apple"`
- Unit: All 25 non-healthy diseaseIds resolve to real DB entries via `getDiseaseById()`
- Integration: `scripts/seed-plantvillage-kb.ts` runs without errors, inserts all 10 plants and 19 diseases
- Integration: After seeding, DB query for each new disease returns a complete record
acceptance_criteria:
- `PLANTVILLAGE_CLASSES` in labels.ts has exactly 38 entries matching model output order
- 13 healthy indices correctly return "healthy" from `getDiseaseIdForIndex()`
- 25 disease indices correctly return valid diseaseIds
- All 10 new plants exist in `src/data/plants.json` with valid metadata and imageUrl
- All 19 new diseases exist in `src/data/diseases.json` with full structured data (symptoms, treatment, prevention, etc.)
- DB migration script runs successfully, all new data queryable from Turso
- Old `diseases.json` ordering assumption is completely removed from labels.ts
- All existing tests still pass (no regressions in browse, search, detail pages)
validation:
- `npx vitest run src/lib/ml/labels.test.ts`
- `npx vitest run src/lib/ml/plantvillage-classes.test.ts`
- `npx tsx scripts/seed-plantvillage-kb.ts` — verify output shows correct inserts
- `npx vitest run` — full test suite passes
- Manual: query DB for each new plant/disease and verify complete data
notes:
- Disease data must come from authoritative sources (university extension services), not hand-written
- Use the same template-based generation approach from `scripts/generate-full-kb.ts` for consistency
- The `pepper-bacterial-wilt` disease already exists — map Pepper\_\_\_Bacterial_spot to it even though it's not a perfect match (it's the closest available)
- Blueberry, Raspberry, and Soybean only have "healthy" classes in PlantVillage — add plant entries but no disease entries for these (they don't need new disease IDs since they always map to "healthy")
- Total disease count after this task: 93 (existing) + 19 (new) = 112 diseases

View File

@@ -0,0 +1,170 @@
# 03. TensorFlow.js Model Loading Verification and Fixes
meta:
id: production-ml-pipeline-03
feature: production-ml-pipeline
priority: P0
depends_on: []
tags: [implementation, model, tests-required]
objective:
- Verify the converted TF.js GraphModel loads successfully on the Node.js server
- Fix input tensor format handling (NCHW pipeline input → NHWC model input)
- Determine whether model output is logits or pre-computed softmax probabilities
- Ensure inference produces valid [1, 38] output without errors
- Install `@tensorflow/tfjs-node` for server-side native acceleration
deliverables:
- `src/lib/ml/model-loader.ts` — fixed and verified for real model loading
- `src/lib/ml/model-loader.test.ts` — updated integration tests
- `package.json``@tensorflow/tfjs-node` added as dependency (if needed)
- `src/lib/ml/inference.ts` — fixed output interpretation (logits vs probabilities)
- `src/lib/ml/inference.test.ts` — updated for real model inference
steps:
1. **Determine output interpretation** — inspect the graph topology to resolve whether `Identity:0` is pre-softmax logits or post-softmax probabilities:
- The model graph contains a `Softmax` node at `StatefulPartitionedCall/mnv2_pv_original_1/dense_1/Softmax`
- The output `Identity:0` may be after Softmax (probabilities) or before (logits)
- Test: run inference on a zero tensor — if output sums to ~1.0, it's already probabilities; if output has negative values or doesn't sum to 1.0, it's logits
- Fix: if output is already probabilities, remove the `softmaxFloat32()` call in `inference.ts` and use the raw output directly
2. **Fix input tensor format** — the model expects NHWC `[1, 160, 160, 3]` but our pipeline produces NCHW `[3, 160, 160]`:
```typescript
// Current code in model-loader.ts tryLoadTFJS():
const inputTensor = tf
.tensor4d(Array.from(tensor), [3, 160, 160])
.transpose([1, 2, 0]) // [160, 160, 3]
.expandDims(0); // [1, 160, 160, 3] NHWC
```
- Verify this transpose is correct (NCHW → NHWC)
- Verify the tensor values are in the expected range (ImageNet-normalized: roughly -2.5 to +2.5)
- Alternative: reshape directly as `[1, 160, 160, 3]` if the identify endpoint produces NHWC data
3. **Install `@tensorflow/tfjs-node`** for server-side native acceleration:
```bash
npm install @tensorflow/tfjs-node
```
- Browser tfjs works on server but is significantly slower (no native BLAS)
- `@tensorflow/tfjs-node` uses libtensorflow C library for ~10-100x speedup
- Verify native bindings install correctly (may need `@tensorflow/tfjs-node-gpu` for GPU, but CPU is fine for this use case)
- Fallback chain remains: tfjs-node → tfjs (browser) → mock
4. **Verify model loads from filesystem**:
```typescript
const model = await tf.loadGraphModel(`file://${MODEL_JSON_PATH}`);
console.log("Model loaded:", model.inputs, model.outputs);
// Expected:
// inputs: [{ shape: [-1, 160, 160, 3], dtype: 'float32' }]
// outputs: [{ shape: [-1, 38], dtype: 'float32' }]
```
- Verify `model.inputs[0].shape` matches `[null, 160, 160, 3]`
- Verify `model.outputs[0].shape` matches `[null, 38]`
- Verify model has `predict()` method (GraphModel uses `predict()`, not `execute()`)
5. **Run inference smoke test**:
```typescript
// Create a test tensor (random normalized values)
const testTensor = new Float32Array(3 * 160 * 160);
for (let i = 0; i < testTensor.length; i++) {
testTensor[i] = (Math.random() - 0.5) * 2;
}
// Reshape to NHWC for TF.js
const input = tf.tensor4d(
Array.from(testTensor),
[1, 160, 160, 3], // NHWC
);
const output = model.predict(input);
const data = await output.data();
console.log("Output shape:", output.shape);
console.log(
"Output sum:",
data.reduce((a, b) => a + b, 0),
);
console.log("Output max:", Math.max(...data));
console.log("Output min:", Math.min(...data));
```
- Output should be [1, 38] with 38 float values
- If values are probabilities: sum ≈ 1.0, all values ≥ 0
- If values are logits: sum ≠ 1.0, may have negative values
6. **Fix `model-loader.ts` `getStatus()` to report real class count**:
```typescript
getStatus(): ModelStatus {
return {
loaded: true,
backend: "tfjs",
modelId: MODEL_ID,
numClasses: 38, // PlantVillage, not 95
};
}
```
7. **Add memory management** — dispose tensors after use to prevent memory leaks:
```typescript
// In predict():
tf.tidy(() => {
const input = tf.tensor4d(...);
const output = model.predict(input);
return output.dataSync();
});
```
- Or manually dispose: `inputTensor.dispose()`, `outputTensor.dispose()`
- Use `tf.memory()` to monitor tensor count during development
8. **Handle model load failures gracefully**:
- If model files are corrupted, log the specific error
- If tfjs-node native bindings fail, fall back to browser tfjs with a warning
- Never crash the server on model load failure — fall back to mock mode with clear logging
tests:
- Integration: model loads from `public/models/plant-disease-classifier/model.json` without errors
- Integration: `model.inputs[0].shape` is `[-1, 160, 160, 3]`
- Integration: `model.outputs[0].shape` is `[-1, 38]`
- Integration: inference on random tensor produces [38] float output
- Integration: if output is probabilities, sum is within 0.991.01
- Integration: `getStatus()` returns `{ loaded: true, backend: "tfjs", numClasses: 38 }`
- Unit: `validateInput()` correctly rejects tensors with wrong length
- Unit: NCHW → NHWC transpose produces correct layout
- Performance: inference completes in < 500ms on a typical server (with tfjs-node)
acceptance_criteria:
- `getModel()` returns a model with `loaded: true` and `backend: "tfjs"`
- `model.predict()` on a valid [1, 160, 160, 3] input returns [1, 38] output without errors
- Output interpretation is correctly determined (logits vs probabilities) and handled
- `@tensorflow/tfjs-node` is installed and used as primary backend
- No memory leaks: tensor count stays stable after repeated inference calls
- Fallback chain works: tfjs-node → tfjs → mock (each failure logs warning)
- Model load time < 30 seconds on first request
- Inference time < 500ms per image on server
validation:
- `npm install @tensorflow/tfjs-node` — native bindings install successfully
- `npx vitest run src/lib/ml/model-loader.test.ts` — all loading tests pass
- `npx vitest run src/lib/ml/inference.test.ts` — all inference tests pass
- Manual: `curl -X POST http://localhost:3000/api/identify -H "Content-Type: application/json" -d '{"imageId":"<existing-id>"}'` — returns real predictions (no `demo_mode: true`)
- Check server logs for `[model-loader] Loaded TF.js model` (not mock fallback)
notes:
- The model file `best_mnv2_pv_original.keras` is the original Keras file — the TF.js conversion is already done (model.json + 3 weight shards)
- The `.keras` file can be deleted after confirming TF.js works, saving ~27MB
- `@tensorflow/tfjs-node` requires libtensorflow — it downloads automatically during npm install
- The `file://` protocol for `loadGraphModel` works with `@tensorflow/tfjs-node` but may not work with browser tfjs (which uses fetch) — if using browser tfjs fallback, need to read file and use `tf.io.loadGraphModel` with a custom loader
- ImageNet normalization in `preprocessImageBuffer()` uses mean=[0.485, 0.456, 0.406] and std=[0.229, 0.224, 0.225] — verify this matches what the PlantVillage model expects (it should, since MobileNetV2 is typically trained with ImageNet preprocessing)

View File

@@ -0,0 +1,207 @@
# 04. Confidence Calibration for PlantVillage Model
meta:
id: production-ml-pipeline-04
feature: production-ml-pipeline
priority: P1
depends_on: [production-ml-pipeline-03]
tags: [implementation, ml, tests-required]
objective:
- Implement proper confidence calibration for the PlantVillage model's softmax output
- Replace the trivial `raw * 1.02` linear calibration with temperature scaling or entropy-based confidence
- Produce meaningful confidence labels (high/medium/low) that correlate with actual correctness
- Handle the "healthy" class output correctly (healthy predictions need different confidence interpretation)
deliverables:
- `src/lib/ml/confidence.ts` — rewritten calibration with temperature scaling
- `src/lib/ml/calibration-params.ts` — calibration parameters (temperature, bias) for PlantVillage model
- `src/lib/ml/confidence.test.ts` — updated tests for new calibration logic
- `scripts/calibrate-model.ts` — script to compute optimal temperature from validation data
steps:
1. **Determine output type** — based on task 03's findings:
- If model output is already softmax probabilities: use entropy-based confidence or inverse-softmax + temperature scaling
- If model output is logits: apply temperature-scaled softmax directly
2. **Implement temperature scaling**:
```typescript
// src/lib/ml/confidence.ts
const DEFAULT_TEMPERATURE = 1.5; // Default for PlantVillage (typically 1.03.0)
export function temperatureScaledSoftmax(
logits: Float32Array,
temperature: number = DEFAULT_TEMPERATURE,
): Float32Array {
const scaled = new Float32Array(logits.length);
for (let i = 0; i < logits.length; i++) {
scaled[i] = logits[i] / temperature;
}
return softmaxFloat32(scaled);
}
```
- Temperature > 1.0 softens the distribution (less confident, more uniform)
- Temperature < 1.0 sharpens the distribution (more confident)
- Temperature = 1.0 is standard softmax (no calibration)
- Typical value for MobileNetV2 on PlantVillage: 1.21.8
3. **Implement entropy-based confidence**:
```typescript
export function computeEntropy(probabilities: Float32Array): number {
let entropy = 0;
for (let i = 0; i < probabilities.length; i++) {
if (probabilities[i] > 1e-10) {
entropy -= probabilities[i] * Math.log(probabilities[i]);
}
}
return entropy;
}
export function entropyToConfidence(
entropy: number,
maxEntropy: number, // ln(numClasses)
): number {
// Normalize entropy to [0, 1], then invert (low entropy = high confidence)
const normalized = entropy / maxEntropy;
return 1 - normalized;
}
```
- For 38 classes: `maxEntropy = Math.log(38) ≈ 3.64`
- Entropy close to 0 → one class dominates → high confidence
- Entropy close to max → uniform distribution → low confidence
4. **Implement combined calibration**:
```typescript
export function calibratePrediction(
output: Float32Array,
isLogits: boolean,
temperature: number = DEFAULT_TEMPERATURE,
): ConfidenceResult {
// Get probabilities (apply softmax if logits, or use directly if already probabilities)
const probs = isLogits ? temperatureScaledSoftmax(output, temperature) : output;
// Get top prediction
let maxIdx = 0;
for (let i = 1; i < probs.length; i++) {
if (probs[i] > probs[maxIdx]) maxIdx = i;
}
const topProb = probs[maxIdx];
// Compute entropy-based confidence
const entropy = computeEntropy(probs);
const maxEntropy = Math.log(probs.length);
const entropyConfidence = entropyToConfidence(entropy, maxEntropy);
// Combine: weighted average of top probability and entropy confidence
const adjusted = 0.7 * topProb + 0.3 * entropyConfidence;
return {
raw: topProb,
adjusted: Math.min(1, Math.max(0, adjusted)),
label: getConfidenceLabel(adjusted),
entropy,
classIndex: maxIdx,
};
}
```
5. **Update `getConfidenceLabel` thresholds** for PlantVillage's 38-class output:
```typescript
const CONFIDENCE_THRESHOLDS = {
HIGH: 0.65, // Lowered from 0.8 — PlantVillage softmax is less peaked
MEDIUM: 0.35, // Lowered from 0.5
} as const;
```
- With 38 classes, even correct predictions may have lower top probability
- These thresholds should be tuned against a validation set (start with defaults, adjust after testing)
6. **Handle healthy class confidence**:
- When the top prediction is a healthy class (index 3, 4, 6, 10, 14, 17, 19, 22, 23, 24, 27, 37), the confidence represents "how confident the model is the plant is healthy"
- Healthy predictions with high confidence → "No disease detected" (good)
- Healthy predictions with low confidence → "Uncertain — may have early symptoms"
- Update `calibrateConfidence()` to accept a `isHealthy` flag and adjust label accordingly
7. **Create calibration parameter module**:
```typescript
// src/lib/ml/calibration-params.ts
export const PLANTVILLAGE_CALIBRATION = {
temperature: 1.5,
confidenceHigh: 0.65,
confidenceMedium: 0.35,
maxEntropy: Math.log(38),
entropyWeight: 0.3,
probabilityWeight: 0.7,
} as const;
```
8. **Create calibration script** `scripts/calibrate-model.ts`:
- Load the model
- Run inference on a set of labeled validation images (from PlantVillage validation split)
- Compute optimal temperature using Nelder-Mead or grid search on negative log-likelihood
- Output the optimal temperature value
- This is optional — start with default 1.5 and refine later
9. **Update `InferenceResult` type** to include calibration metadata:
```typescript
export interface InferenceResult {
predictions: RawPrediction[];
inferenceTimeMs: number;
calibration?: {
temperature: number;
entropy: number;
entropyConfidence: number;
};
}
```
tests:
- Unit: `temperatureScaledSoftmax` with T=1.0 equals standard softmax
- Unit: `temperatureScaledSoftmax` with T=2.0 produces more uniform distribution than T=1.0
- Unit: `computeEntropy` of uniform distribution = `Math.log(38)` ≈ 3.64
- Unit: `computeEntropy` of one-hot distribution = 0
- Unit: `entropyToConfidence(0, maxEntropy)` = 1.0 (maximum confidence)
- Unit: `entropyToConfidence(maxEntropy, maxEntropy)` = 0.0 (minimum confidence)
- Unit: `calibratePrediction` with high-peak input returns high confidence
- Unit: `calibratePrediction` with flat input returns low confidence
- Unit: `getConfidenceLabel(0.7)` returns "high"
- Unit: `getConfidenceLabel(0.4)` returns "medium"
- Unit: `getConfidenceLabel(0.2)` returns "low"
- Integration: calibration on known PlantVillage test image produces reasonable confidence
acceptance_criteria:
- `calibratePrediction()` produces meaningful confidence scores that correlate with prediction quality
- Temperature scaling is implemented and configurable (default T=1.5)
- Entropy-based confidence is implemented
- Combined calibration (weighted probability + entropy) is the default
- Healthy class predictions are handled correctly
- Confidence thresholds are tuned for 38-class output (HIGH ≥ 0.65, MEDIUM ≥ 0.35)
- All unit tests pass
- Calibration parameters are documented and configurable
validation:
- `npx vitest run src/lib/ml/confidence.test.ts`
- Manual: run identification on a known disease image → confidence should be "high" (> 0.65)
- Manual: run identification on a random/unrelated image → confidence should be "low" (< 0.35)
- Check server logs: entropy values should be reasonable (1.03.5 range for 38 classes)
notes:
- Temperature scaling is a post-hoc calibration method — it doesn't change the model, only the confidence interpretation
- The default temperature of 1.5 is a reasonable starting point for MobileNetV2 on PlantVillage. Optimal value depends on the specific training run.
- If a validation set of PlantVillage images is available, run `scripts/calibrate-model.ts` to find the optimal temperature
- The entropy-based approach works even without a validation set — it's a model-agnostic confidence measure
- For healthy predictions, consider showing a different UI (e.g., "No disease detected" with confidence) rather than treating them as disease predictions

View File

@@ -0,0 +1,279 @@
# 05. Real Model Integration into Identification Pipeline
meta:
id: production-ml-pipeline-05
feature: production-ml-pipeline
priority: P0
depends_on: [production-ml-pipeline-02, production-ml-pipeline-03, production-ml-pipeline-04]
tags: [implementation, integration, tests-required]
objective:
- Wire the real TF.js model into the `/api/identify` endpoint
- Replace demo/mock predictions with real model inference
- Use the PlantVillage label mapping (task 02) to resolve class indices to disease IDs
- Apply confidence calibration (task 04) to produce meaningful confidence scores
- Remove the `demo_mode` fallback path
- Handle healthy class predictions correctly (return "no disease detected" message)
deliverables:
- `src/app/api/identify/route.ts` — rewritten to use real model inference
- `src/lib/ml/inference.ts` — updated to use calibration and return structured results
- `src/lib/api/identify.ts` — client-side API updated for new response shape
- `src/components/ResultsDashboard.tsx` — handle healthy predictions and remove demo mode badge
- `src/components/HealthyResult.tsx` — new component for "no disease detected" state
steps:
1. **Rewrite `/api/identify` route handler** to use real inference:
```typescript
export async function POST(request: NextRequest) {
// 1. Parse request, validate imageId
// 2. Load and preprocess image (existing code)
// 3. Run inference with real model
const { probabilities, inferenceTimeMs } = await runInference(tensor);
// 4. Calibrate confidence
const calibrated = calibratePrediction(probabilities, isLogits);
// 5. Map to disease using PlantVillage labels
const diseaseId = getDiseaseIdForIndex(calibrated.classIndex);
const isHealthy = isHealthyClass(calibrated.classIndex);
// 6. If healthy, return healthy result
if (isHealthy && calibrated.adjusted > 0.5) {
return NextResponse.json({
healthy: true,
plantId: getPlantIdForIndex(calibrated.classIndex),
confidence: calibrated,
metadata: { model: MODEL_ID, inferenceTimeMs, imageId },
});
}
// 7. Get top-K predictions (not just top-1)
const topK = getTopKFloat32(probabilities, 5);
const predictions = await enrichPredictions(topK);
// 8. Return results
return NextResponse.json({
predictions,
metadata: { model: MODEL_ID, inferenceTimeMs, imageId },
demo_mode: false, // or remove this field entirely
});
}
```
2. **Update `runInference()` to return calibrated results**:
```typescript
export async function runInference(
imageTensor: Float32Array,
topK: number = 5,
): Promise<InferenceResult> {
const model = await getModel();
const modelStatus = model.getStatus();
if (!modelStatus.loaded) {
throw new Error("Model not loaded. Cannot run inference.");
}
const { output, inferenceTimeMs } = await model.predict(imageTensor);
// Determine if output is logits or probabilities
const isLogits = !isProbabilities(output);
// Apply calibration
const calibration = calibratePrediction(output, isLogits);
// Get top-K predictions
const probs = isLogits ? temperatureScaledSoftmax(output) : output;
const topKPredictions = getTopKFloat32(probs, topK);
return {
predictions: topKPredictions,
inferenceTimeMs,
calibration: {
temperature: PLANTVILLAGE_CALIBRATION.temperature,
entropy: calibration.entropy,
entropyConfidence: calibration.entropyConfidence,
},
};
}
function isProbabilities(output: Float32Array): boolean {
const sum = output.reduce((a, b) => a + b, 0);
return Math.abs(sum - 1.0) < 0.01;
}
```
3. **Update `enrichPredictions()` to use new label mapping**:
```typescript
async function enrichPredictions(
topPredictions: Array<{ classIndex: number; probability: number }>,
): Promise<PredictionResult[]> {
const results: PredictionResult[] = [];
for (const pred of topPredictions) {
// Skip healthy classes in top-K (they're handled separately)
if (isHealthyClass(pred.classIndex)) continue;
const diseaseId = getDiseaseIdForIndex(pred.classIndex);
const plantId = getPlantIdForIndex(pred.classIndex);
if (!diseaseId || diseaseId === "healthy") continue;
const disease = await getDiseaseById(diseaseId);
if (!disease) continue;
// Use probability as raw confidence, calibrate with entropy
const confidence = calibrateConfidence(pred.probability);
const plant = await getPlantById(disease.plantId).catch(() => null);
results.push({
diseaseId,
disease,
confidence,
lookalikes: disease.lookalikeDiseaseIds,
plant: plant ?? null,
});
}
results.sort((a, b) => b.confidence.adjusted - a.confidence.adjusted);
return results;
}
```
4. **Update response types** to support healthy result:
```typescript
// src/lib/types.ts
export interface IdentifyResponse {
predictions?: PredictionResult[];
healthy?: boolean;
plantId?: string;
confidence?: ConfidenceResult;
metadata: InferenceMetadata;
demo_mode?: boolean; // Remove or always false
}
```
5. **Update `ResultsDashboard` component** to handle healthy result:
```tsx
// If response.healthy === true, show HealthyResult component instead of prediction cards
if (response?.healthy) {
return <HealthyResult plantId={response.plantId} confidence={response.confidence} />;
}
```
6. **Create `HealthyResult` component** `src/components/HealthyResult.tsx`:
```tsx
export default function HealthyResult({ plantId, confidence }) {
const plant = usePlant(plantId); // fetch plant data
return (
<div className="...">
<div className="text-6xl">🌿</div>
<h2>No Disease Detected</h2>
<p>
The image appears healthy{plant ? ` (${plant.commonName})` : ""}. Confidence:{" "}
{Math.round(confidence.adjusted * 100)}%
</p>
<p className="text-sm text-zinc-500">
If symptoms persist, try uploading a clearer photo of the affected area.
</p>
</div>
);
}
```
7. **Remove demo mode logic**:
- In `model-loader.ts`: remove `createMockModel()` fallback (or keep it but only for development)
- In `route.ts`: remove `demo_mode: true` branch
- In `ResultsDashboard.tsx`: remove "Demo mode" badge
- In `src/lib/api/identify.ts`: remove `demo_mode` from response type
8. **Add error handling for model not loaded**:
```typescript
const model = await getModel();
if (!model.getStatus().loaded) {
return NextResponse.json(
{
error: "Model not available",
message: "ML model failed to load. Please try again later.",
},
{ status: 503 },
);
}
```
9. **Update client-side API** `src/lib/api/identify.ts`:
```typescript
export interface IdentifyResponse {
predictions?: PredictionResult[];
healthy?: boolean;
plantId?: string;
confidence?: ConfidenceResult;
metadata: InferenceMetadata;
}
```
10. **Add structured logging** for inference requests:
```typescript
console.log(
JSON.stringify({
event: "inference",
imageId,
modelId: MODEL_ID,
inferenceTimeMs,
topPrediction: predictions[0]?.diseaseId,
confidence: predictions[0]?.confidence.adjusted,
entropy: calibration?.entropy,
}),
);
```
tests:
- Integration: POST `/api/identify` with valid imageId returns real predictions (no `demo_mode: true`)
- Integration: response includes `predictions` array with valid diseaseIds from KB
- Integration: confidence scores are calibrated (not raw softmax)
- Integration: healthy predictions return `healthy: true` with plantId
- Unit: `enrichPredictions()` skips healthy classes in top-K
- Unit: `isProbabilities()` correctly identifies probability output
- Unit: `runInference()` throws error if model not loaded
- E2E: upload a tomato leaf image → get tomato disease predictions
- E2E: upload a healthy plant image → get healthy result
acceptance_criteria:
- `/api/identify` returns real model predictions (not mock)
- All diseaseIds in response are valid KB entries (verifiable via `getDiseaseById()`)
- Confidence scores use temperature-scaled calibration (not raw softmax)
- Healthy predictions return `{ healthy: true, plantId, confidence }` instead of disease predictions
- Demo mode is completely removed from production path
- Error handling: model not loaded → 503 response with clear message
- Structured logging for every inference request
- Client-side API handles new response shape (healthy vs predictions)
validation:
- `npx vitest run src/app/api/identify/identify.test.ts`
- `npx vitest run src/lib/ml/inference.test.ts`
- `curl -X POST http://localhost:3000/api/identify -H "Content-Type: application/json" -d '{"imageId":"<test-id>"}'` — response has real predictions
- Upload a test image via UI → see real disease names (not demo mode)
- Check server logs: `event: "inference"` with valid modelId and inferenceTimeMs
notes:
- This task depends on tasks 02, 03, and 04 being complete. Do not start until all dependencies are met.
- The `enrichPredictions()` function now skips healthy classes — they're handled by the healthy result path
- If the model is not loaded, return 503 (Service Unavailable) instead of falling back to mock
- Structured logging should be JSON for easy parsing by log aggregators
- The `demo_mode` field can be removed entirely or kept as `false` for backwards compatibility

View File

@@ -0,0 +1,284 @@
# 06. Plant-Context-Aware Identification
meta:
id: production-ml-pipeline-06
feature: production-ml-pipeline
priority: P2
depends_on: [production-ml-pipeline-05]
tags: [implementation, ux, tests-required]
objective:
- Allow users to optionally specify which plant they're diagnosing before identification
- Boost predictions for the selected plant's diseases (multiply confidence by plant-context factor)
- Update the upload flow to include optional plant selection
- Improve prediction accuracy when plant context is known
deliverables:
- `src/app/api/identify/route.ts` — accept optional `plantId` parameter
- `src/lib/ml/plant-context.ts` — new module for plant-context scoring adjustment
- `src/components/PlantSelector.tsx` — new component for optional plant selection
- `src/app/upload/page.tsx` — integrate PlantSelector before upload
- `src/lib/api/identify.ts` — client API updated to pass plantId
steps:
1. **Create plant-context scoring module** `src/lib/ml/plant-context.ts`:
```typescript
import { PLANTVILLAGE_CLASSES } from "./plantvillage-classes";
/**
* Adjust prediction scores based on plant context.
* If plantId is provided, boost predictions for diseases of that plant.
*
* @param predictions - Top-K predictions with classIndex and probability
* @param plantId - Optional plant ID from user selection
* @param boostFactor - Multiplier for matching plant diseases (default 1.5)
* @returns Adjusted predictions with updated probabilities
*/
export function applyPlantContext(
predictions: Array<{ classIndex: number; probability: number }>,
plantId: string | null,
boostFactor: number = 1.5,
): Array<{ classIndex: number; probability: number; contextBoosted: boolean }> {
if (!plantId) {
return predictions.map((p) => ({ ...p, contextBoosted: false }));
}
// Find which class indices belong to this plant
const plantIndices = new Set(
PLANTVILLAGE_CLASSES.filter((c) => c.plantId === plantId && !c.isHealthy).map(
(c) => c.index,
),
);
return predictions.map((pred) => {
const matchesPlant = plantIndices.has(pred.classIndex);
return {
classIndex: pred.classIndex,
probability: matchesPlant
? Math.min(1.0, pred.probability * boostFactor)
: pred.probability,
contextBoosted: matchesPlant,
};
});
}
```
2. **Update `/api/identify` route** to accept `plantId`:
```typescript
export async function POST(request: NextRequest) {
const body = await request.json();
const { imageId, plantId } = body; // plantId is optional
// ... existing preprocessing ...
const { probabilities, inferenceTimeMs } = await runInference(tensor);
// Get top-K predictions
const topK = getTopKFloat32(probabilities, 5);
// Apply plant context if provided
const adjusted = applyPlantContext(topK, plantId ?? null);
// Enrich with KB data
const predictions = await enrichPredictions(adjusted);
return NextResponse.json({
predictions,
metadata: { model: MODEL_ID, inferenceTimeMs, imageId, plantContext: plantId ?? null },
});
}
```
3. **Update `IdentifyRequest` type**:
```typescript
// src/lib/types.ts
export interface IdentifyRequest {
imageId: string;
plantId?: string; // Optional plant context
}
```
4. **Create `PlantSelector` component** `src/components/PlantSelector.tsx`:
```tsx
"use client";
import { useState, useEffect } from "react";
interface Plant {
id: string;
commonName: string;
imageUrl?: string;
}
export default function PlantSelector({
value,
onChange,
}: {
value: string | null;
onChange: (plantId: string | null) => void;
}) {
const [plants, setPlants] = useState<Plant[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
fetch("/api/plants?limit=50")
.then((r) => r.json())
.then((data) => setPlants(data.items ?? []));
}, []);
const filtered = plants.filter((p) =>
p.commonName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="...">
<label>Plant (optional)</label>
<input
type="text"
placeholder="Search plants..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{value && (
<div className="...">
Selected: {plants.find((p) => p.id === value)?.commonName}
<button onClick={() => onChange(null)}>Clear</button>
</div>
)}
<ul>
{filtered.slice(0, 10).map((plant) => (
<li key={plant.id} onClick={() => onChange(plant.id)}>
{plant.commonName}
</li>
))}
</ul>
</div>
);
}
```
5. **Update upload page** to include plant selector:
```tsx
// src/app/upload/page.tsx
export default function UploadPage() {
const [selectedPlant, setSelectedPlant] = useState<string | null>(null);
const handleUpload = useCallback(
async (file: File) => {
// 1. Upload image
const uploadResponse = await uploadImage(file);
// 2. Identify with plant context
const identifyResponse = await identifyPlant(uploadResponse.imageId, selectedPlant);
// 3. Navigate to results
router.push(`/results/${uploadResponse.imageId}`);
},
[selectedPlant],
);
return (
<div>
<PlantSelector value={selectedPlant} onChange={setSelectedPlant} />
<ImageUpload onUpload={handleUpload} />
</div>
);
}
```
6. **Update client-side API** to pass plantId:
```typescript
// src/lib/api/identify.ts
export async function identifyPlant(
imageId: string,
plantId?: string,
): Promise<IdentifyResponse> {
const body: IdentifyRequest = { imageId };
if (plantId) body.plantId = plantId;
const response = await fetch("/api/identify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
```
7. **Update `PredictionResult` type** to include context boost info:
```typescript
export interface PredictionResult {
diseaseId: string;
disease: Disease;
confidence: ConfidenceResult;
lookalikes: string[];
plant: Plant | null;
contextBoosted?: boolean; // true if boosted by plant context
}
```
8. **Update `ResultsDashboard`** to show context boost indicator:
```tsx
{
prediction.contextBoosted && (
<span className="text-xs text-leaf-green-600">✓ Matches selected plant</span>
);
}
```
9. **Store plant context in results page** — pass plantId through URL or state:
```typescript
// src/app/results/[imageId]/page.tsx
const plantId = searchParams.get("plant"); // optional
const response = await identifyPlant(imageId, plantId);
```
tests:
- Unit: `applyPlantContext()` with no plantId returns predictions unchanged
- Unit: `applyPlantContext()` with plantId="tomato" boosts tomato disease predictions
- Unit: boosted probabilities are capped at 1.0
- Unit: non-matching plant predictions are unchanged
- Unit: `contextBoosted` flag is set correctly
- Integration: POST `/api/identify` with plantId returns boosted predictions
- Integration: POST `/api/identify` without plantId returns normal predictions
- E2E: select "Tomato" in UI → upload tomato leaf → tomato diseases appear first
acceptance_criteria:
- Plant context is optional — identification works without it
- When plantId is provided, predictions for that plant's diseases are boosted by 1.5x
- Boosted probabilities are capped at 1.0
- `contextBoosted` flag is set on boosted predictions
- UI shows "Matches selected plant" indicator on boosted predictions
- Plant selector component works (search, select, clear)
- Upload flow includes optional plant selection step
- Results page receives and displays plant context
validation:
- `npx vitest run src/lib/ml/plant-context.test.ts`
- `npx vitest run src/components/PlantSelector.test.tsx`
- Manual: select "Tomato" → upload image → tomato diseases appear with boost indicator
- Manual: don't select plant → upload image → normal predictions (no boost)
- Check API response: `predictions[0].contextBoosted` is true when plant matches
notes:
- Plant context is a scoring heuristic, not a hard filter. It boosts confidence but doesn't exclude other predictions.
- The default boost factor is 1.5 — this can be tuned based on user feedback.
- Plant selector is optional — users can skip it and get unboosted predictions.
- The plant context feature is most useful when the user knows what plant they're diagnosing but the model is uncertain between multiple diseases.
- For PlantVillage, each plant has 19 diseases, so the boost is specific enough to be useful without being overly restrictive.

View File

@@ -0,0 +1,292 @@
# 07. End-to-End Integration Testing
meta:
id: production-ml-pipeline-07
feature: production-ml-pipeline
priority: P1
depends_on: [production-ml-pipeline-05]
tags: [testing, integration, e2e]
objective:
- Create comprehensive end-to-end tests that validate the full pipeline from image upload to disease diagnosis
- Verify real model inference produces valid, calibrated predictions
- Test all code paths: normal flow, healthy result, error cases, plant context
- Ensure all components work together correctly in a realistic scenario
deliverables:
- `tests/e2e/pipeline.test.ts` — full pipeline E2E tests
- `tests/e2e/fixtures/` — test images and expected results
- `tests/e2e/utils.ts` — test utilities (upload helper, identify helper)
- Updated `vitest.config.ts` — E2E test configuration
steps:
1. **Create test fixtures** `tests/e2e/fixtures/`:
- `tomato-early-blight.jpg` — known tomato early blight image (from PlantVillage test set)
- `tomato-healthy.jpg` — known healthy tomato image
- `unknown-plant.jpg` — unrelated image (should produce low confidence)
- `invalid-image.txt` — non-image file (should fail validation)
- `expected-results.json` — expected disease IDs and confidence ranges for each test image
2. **Create E2E test utilities** `tests/e2e/utils.ts`:
```typescript
import fs from "fs/promises";
import path from "path";
export async function uploadTestImage(
filename: string,
): Promise<{ imageId: string; previewUrl: string }> {
const imagePath = path.join(__dirname, "fixtures", filename);
const imageBuffer = await fs.readFile(imagePath);
const formData = new FormData();
formData.append("image", new Blob([imageBuffer], { type: "image/jpeg" }), filename);
const response = await fetch("http://localhost:3000/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
return response.json();
}
export async function identifyImage(imageId: string, plantId?: string): Promise<any> {
const response = await fetch("http://localhost:3000/api/identify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId, plantId }),
});
if (!response.ok) {
throw new Error(`Identify failed: ${response.status}`);
}
return response.json();
}
```
3. **Write full pipeline E2E test** `tests/e2e/pipeline.test.ts`:
```typescript
import { describe, it, expect, beforeAll } from "vitest";
import { uploadTestImage, identifyImage } from "./utils";
import expectedResults from "./fixtures/expected-results.json";
describe("End-to-End Pipeline", () => {
describe("Normal flow: disease detection", () => {
it("uploads a tomato early blight image and returns correct diagnosis", async () => {
// 1. Upload
const { imageId } = await uploadTestImage("tomato-early-blight.jpg");
expect(imageId).toBeDefined();
// 2. Identify
const result = await identifyImage(imageId);
// 3. Verify response structure
expect(result.predictions).toBeDefined();
expect(result.predictions.length).toBeGreaterThan(0);
expect(result.metadata).toBeDefined();
expect(result.metadata.model).toBe("plant-classifier-v1");
expect(result.metadata.inferenceTimeMs).toBeGreaterThan(0);
expect(result.demo_mode).toBeFalsy();
// 4. Verify top prediction is early blight
const topPrediction = result.predictions[0];
expect(topPrediction.diseaseId).toBe("early-blight");
expect(topPrediction.disease.name).toContain("Early Blight");
expect(topPrediction.plant.id).toBe("tomato");
// 5. Verify confidence is calibrated
expect(topPrediction.confidence.adjusted).toBeGreaterThan(0.5);
expect(topPrediction.confidence.label).toBe("high");
// 6. Verify disease data is enriched
expect(topPrediction.disease.symptoms.length).toBeGreaterThanOrEqual(3);
expect(topPrediction.disease.treatment.length).toBeGreaterThanOrEqual(3);
expect(topPrediction.disease.prevention.length).toBeGreaterThanOrEqual(2);
});
});
describe("Healthy result", () => {
it("returns healthy result for healthy plant image", async () => {
const { imageId } = await uploadTestImage("tomato-healthy.jpg");
const result = await identifyImage(imageId);
// Should return healthy: true or top prediction is a healthy class
if (result.healthy) {
expect(result.healthy).toBe(true);
expect(result.plantId).toBe("tomato");
expect(result.confidence.adjusted).toBeGreaterThan(0.5);
} else {
// If not healthy result, confidence should be low
const topPrediction = result.predictions[0];
expect(topPrediction.confidence.adjusted).toBeLessThan(0.5);
}
});
});
describe("Unknown image", () => {
it("returns low confidence for unrelated image", async () => {
const { imageId } = await uploadTestImage("unknown-plant.jpg");
const result = await identifyImage(imageId);
// Should have predictions but with low confidence
if (result.predictions) {
const topPrediction = result.predictions[0];
expect(topPrediction.confidence.adjusted).toBeLessThan(0.5);
expect(topPrediction.confidence.label).toBe("low");
}
});
});
describe("Plant context", () => {
it("boosts predictions when plantId is provided", async () => {
const { imageId } = await uploadTestImage("tomato-early-blight.jpg");
// Without plant context
const resultNoContext = await identifyImage(imageId);
const confidenceNoContext = resultNoContext.predictions[0].confidence.adjusted;
// With plant context
const resultWithContext = await identifyImage(imageId, "tomato");
const confidenceWithContext = resultWithContext.predictions[0].confidence.adjusted;
// Context should boost confidence (or at least not reduce it)
expect(confidenceWithContext).toBeGreaterThanOrEqual(confidenceNoContext);
// Boosted prediction should have contextBoosted flag
const boosted = resultWithContext.predictions.find((p) => p.contextBoosted);
expect(boosted).toBeDefined();
});
});
describe("Error cases", () => {
it("returns 404 for non-existent imageId", async () => {
const response = await fetch("http://localhost:3000/api/identify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageId: "non-existent-id" }),
});
expect(response.status).toBe(404);
});
it("returns 400 for invalid image upload", async () => {
const formData = new FormData();
formData.append("image", new Blob(["not an image"], { type: "text/plain" }), "test.txt");
const response = await fetch("http://localhost:3000/api/upload", {
method: "POST",
body: formData,
});
expect(response.status).toBe(400);
});
});
describe("Performance", () => {
it("completes inference in under 500ms", async () => {
const { imageId } = await uploadTestImage("tomato-early-blight.jpg");
const start = Date.now();
await identifyImage(imageId);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(500);
});
});
});
```
4. **Create expected results fixture** `tests/e2e/fixtures/expected-results.json`:
```json
{
"tomato-early-blight.jpg": {
"expectedDiseaseId": "early-blight",
"expectedPlantId": "tomato",
"minConfidence": 0.6,
"expectedConfidenceLabel": "high"
},
"tomato-healthy.jpg": {
"expectedHealthy": true,
"expectedPlantId": "tomato",
"minConfidence": 0.5
},
"unknown-plant.jpg": {
"maxConfidence": 0.5,
"expectedConfidenceLabel": "low"
}
}
```
5. **Update vitest config** to support E2E tests:
```typescript
// vitest.config.ts
export default defineConfig({
test: {
// ... existing config ...
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
},
});
```
6. **Add E2E test script** to `package.json`:
```json
{
"scripts": {
"test:e2e": "vitest run tests/e2e"
}
}
```
7. **Document E2E test setup** in `tests/e2e/README.md`:
- Requires dev server running (`npm run dev`)
- Requires model files present (`public/models/plant-disease-classifier/`)
- Requires test fixtures (download PlantVillage test images)
- Run with `npm run test:e2e`
8. **Download test images** from PlantVillage dataset:
- Use images from the PlantVillage test split (not training)
- Place in `tests/e2e/fixtures/`
- Document source and license
tests:
- E2E: full pipeline test (upload → identify → verify results)
- E2E: healthy result detection
- E2E: unknown image produces low confidence
- E2E: plant context boosts predictions
- E2E: error cases (404, 400)
- E2E: performance (< 500ms inference)
acceptance_criteria:
- All E2E tests pass with real model inference
- Test fixtures are documented and licensed appropriately
- E2E tests can be run with `npm run test:e2e`
- Tests cover: normal flow, healthy result, unknown image, plant context, errors, performance
- Test results are deterministic (no flaky tests)
validation:
- `npm run test:e2e` — all tests pass
- Manual: run tests against dev server and verify output
- Check test coverage: all major code paths are exercised
notes:
- E2E tests require the dev server to be running (`npm run dev`)
- Test images should be from PlantVillage test split (not training) to avoid overfitting concerns
- If test images are not available, use synthetic test data (random tensors) for CI
- Performance test threshold (500ms) is generous — actual inference should be < 200ms with tfjs-node
- E2E tests are separate from unit tests — run them in CI after deployment to staging

View File

@@ -0,0 +1,405 @@
# 08. Production Hardening and Observability
meta:
id: production-ml-pipeline-08
feature: production-ml-pipeline
priority: P1
depends_on: [production-ml-pipeline-07]
tags: [implementation, production, observability]
objective:
- Add comprehensive error handling at every layer of the pipeline
- Implement structured logging for observability
- Add rate limiting to prevent abuse
- Create a health endpoint that reports model status and inference metrics
- Ensure the system is production-ready with monitoring, cleanup, and resilience
deliverables:
- `src/app/api/health/route.ts` — enhanced health endpoint with model status
- `src/lib/middleware/rate-limit.ts` — rate limiting middleware
- `src/lib/middleware/error-handler.ts` — global error handler
- `src/lib/observability/logger.ts` — structured logger
- `src/lib/observability/metrics.ts` — inference metrics tracker
- Updated API routes with error handling and logging
- Updated `next.config.ts` with rate limiting configuration
steps:
1. **Create structured logger** `src/lib/observability/logger.ts`:
```typescript
export interface LogEntry {
timestamp: string;
level: "debug" | "info" | "warn" | "error";
event: string;
data?: Record<string, any>;
error?: { message: string; stack?: string };
}
export function log(level: LogEntry["level"], event: string, data?: Record<string, any>) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
event,
data,
};
if (level === "error" && data?.error) {
entry.error = {
message: data.error.message,
stack: data.error.stack,
};
}
console.log(JSON.stringify(entry));
}
export const logger = {
debug: (event: string, data?: any) => log("debug", event, data),
info: (event: string, data?: any) => log("info", event, data),
warn: (event: string, data?: any) => log("warn", event, data),
error: (event: string, data?: any) => log("error", event, data),
};
```
2. **Create metrics tracker** `src/lib/observability/metrics.ts`:
```typescript
interface InferenceMetrics {
totalInferences: number;
totalErrors: number;
avgInferenceTimeMs: number;
lastInferenceAt: string | null;
modelLoaded: boolean;
modelLoadTimeMs: number | null;
}
class MetricsTracker {
private metrics: InferenceMetrics = {
totalInferences: 0,
totalErrors: 0,
avgInferenceTimeMs: 0,
lastInferenceAt: null,
modelLoaded: false,
modelLoadTimeMs: null,
};
recordInference(inferenceTimeMs: number) {
this.metrics.totalInferences++;
this.metrics.lastInferenceAt = new Date().toISOString();
// Running average
this.metrics.avgInferenceTimeMs =
(this.metrics.avgInferenceTimeMs * (this.metrics.totalInferences - 1) + inferenceTimeMs) /
this.metrics.totalInferences;
}
recordError() {
this.metrics.totalErrors++;
}
setModelStatus(loaded: boolean, loadTimeMs?: number) {
this.metrics.modelLoaded = loaded;
if (loadTimeMs !== undefined) {
this.metrics.modelLoadTimeMs = loadTimeMs;
}
}
getMetrics(): InferenceMetrics {
return { ...this.metrics };
}
}
export const metrics = new MetricsTracker();
```
3. **Enhance health endpoint** `src/app/api/health/route.ts`:
```typescript
import { NextResponse } from "next/server";
import { getModel } from "@/lib/ml/model-loader";
import { metrics } from "@/lib/observability/metrics";
export async function GET() {
const model = await getModel();
const modelStatus = model.getStatus();
return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
model: {
loaded: modelStatus.loaded,
backend: modelStatus.backend,
modelId: modelStatus.modelId,
numClasses: modelStatus.numClasses,
error: modelStatus.error,
},
metrics: metrics.getMetrics(),
uptime: process.uptime(),
});
}
```
4. **Create rate limiting middleware** `src/lib/middleware/rate-limit.ts`:
```typescript
import { NextRequest, NextResponse } from "next/server";
// Simple in-memory rate limiter (for production, use Redis or similar)
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = {
maxRequests: 10, // 10 requests per window
windowMs: 60 * 1000, // 1 minute window
};
export function rateLimit(request: NextRequest): NextResponse | null {
const ip = request.headers.get("x-forwarded-for") || "unknown";
const now = Date.now();
let record = requestCounts.get(ip);
if (!record || now > record.resetAt) {
record = { count: 0, resetAt: now + RATE_LIMIT.windowMs };
requestCounts.set(ip, record);
}
record.count++;
if (record.count > RATE_LIMIT.maxRequests) {
return NextResponse.json(
{ error: "Rate limit exceeded", message: "Too many requests. Please try again later." },
{ status: 429 },
);
}
return null; // No rate limit hit
}
```
5. **Create global error handler** `src/lib/middleware/error-handler.ts`:
```typescript
import { NextResponse } from "next/server";
import { logger } from "@/lib/observability/logger";
export function handleError(error: unknown, context: string): NextResponse {
logger.error("unhandled_error", {
context,
error:
error instanceof Error
? { message: error.message, stack: error.stack }
: { message: String(error) },
});
return NextResponse.json(
{
error: "Internal server error",
message: "An unexpected error occurred. Please try again later.",
context,
},
{ status: 500 },
);
}
```
6. **Add error handling to `/api/upload`**:
```typescript
import { rateLimit } from "@/lib/middleware/rate-limit";
import { handleError } from "@/lib/middleware/error-handler";
import { logger } from "@/lib/observability/logger";
export async function POST(request: NextRequest) {
// Rate limiting
const rateLimitError = rateLimit(request);
if (rateLimitError) return rateLimitError;
try {
logger.info("upload_start", { ip: request.headers.get("x-forwarded-for") });
// ... existing upload logic ...
logger.info("upload_success", { imageId, fileSize: buffer.length });
return NextResponse.json({ imageId, tensorShape, previewUrl });
} catch (error) {
return handleError(error, "upload");
}
}
```
7. **Add error handling to `/api/identify`**:
```typescript
export async function POST(request: NextRequest) {
const rateLimitError = rateLimit(request);
if (rateLimitError) return rateLimitError;
try {
logger.info("identify_start", { imageId, plantId });
const startTime = Date.now();
// ... existing identify logic ...
const inferenceTimeMs = Date.now() - startTime;
metrics.recordInference(inferenceTimeMs);
logger.info("identify_success", {
imageId,
inferenceTimeMs,
topPrediction: predictions[0]?.diseaseId,
confidence: predictions[0]?.confidence.adjusted,
});
return NextResponse.json({ predictions, metadata });
} catch (error) {
metrics.recordError();
if (error instanceof Error && error.message.includes("not loaded")) {
return NextResponse.json(
{
error: "Model not available",
message: "ML model failed to load. Please try again later.",
},
{ status: 503 },
);
}
return handleError(error, "identify");
}
}
```
8. **Add model status tracking to `model-loader.ts`**:
```typescript
import { metrics } from "@/lib/observability/metrics";
async function loadModel(): Promise<PlantDiseaseModel> {
const startTime = Date.now();
try {
const model = await tryLoadTFJS();
if (model) {
const loadTimeMs = Date.now() - startTime;
metrics.setModelStatus(true, loadTimeMs);
logger.info("model_loaded", { backend: "tfjs", loadTimeMs });
return model;
}
} catch (error) {
logger.warn("model_load_failed", { backend: "tfjs", error });
}
// ... fallback to mock ...
metrics.setModelStatus(false);
return createMockModel();
}
```
9. **Add cleanup for old uploads**:
```typescript
// src/lib/cleanup.ts
import fs from "fs/promises";
import path from "path";
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads");
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
export async function cleanupOldUploads() {
const files = await fs.readdir(UPLOADS_DIR);
const now = Date.now();
for (const file of files) {
const filePath = path.join(UPLOADS_DIR, file);
const stat = await fs.stat(filePath);
if (now - stat.mtimeMs > MAX_AGE_MS) {
await fs.unlink(filePath);
logger.info("upload_cleaned", { file, ageMs: now - stat.mtimeMs });
}
}
}
// Run cleanup on server start and periodically
if (process.env.NODE_ENV === "production") {
cleanupOldUploads();
setInterval(cleanupOldUploads, 60 * 60 * 1000); // Every hour
}
```
10. **Update `next.config.ts`** with security headers and rate limiting:
```typescript
const nextConfig = {
// ... existing config ...
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-XSS-Protection", value: "1; mode=block" },
],
},
];
},
};
```
11. **Add monitoring dashboard** (optional) `src/app/admin/metrics/page.tsx`:
- Simple page showing inference metrics
- Model status
- Recent inference times
- Error rate
- Protected by authentication (admin only)
12. **Document production checklist** in `docs/production-checklist.md`:
- Environment variables needed
- Model deployment steps
- Monitoring setup
- Backup strategy
- Rollback procedure
tests:
- Unit: rate limiter blocks after max requests
- Unit: rate limiter resets after window
- Unit: metrics tracker records inference correctly
- Unit: metrics tracker computes running average
- Unit: logger produces valid JSON output
- Integration: health endpoint returns model status and metrics
- Integration: rate limit returns 429 after max requests
- Integration: error handler catches unhandled errors and returns 500
acceptance_criteria:
- All API routes have rate limiting (10 requests per minute per IP)
- All API routes have structured logging (JSON format)
- Health endpoint reports model status, inference metrics, uptime
- Error handler catches all unhandled errors and returns 500 with clear message
- Old uploads are cleaned up automatically (24-hour TTL)
- Metrics tracker records inference time, error rate, model status
- Security headers are set (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection)
- Production checklist is documented
validation:
- `npx vitest run src/lib/middleware/rate-limit.test.ts`
- `npx vitest run src/lib/observability/metrics.test.ts`
- `curl http://localhost:3000/api/health` — returns model status and metrics
- `curl -X POST http://localhost:3000/api/identify ...` (11 times) — 11th request returns 429
- Check server logs: JSON-formatted log entries for all requests
- Wait 25 minutes: old uploads are cleaned up
notes:
- Rate limiter uses in-memory storage — for multi-instance deployments, use Redis or similar
- Metrics are in-memory — for persistent metrics, use a time-series database
- Health endpoint should be monitored by uptime monitoring service (e.g., Pingdom, UptimeRobot)
- Cleanup runs every hour in production — adjust frequency based on upload volume
- Security headers are basic — consider adding CSP, HSTS for full security hardening
- Production checklist should be reviewed before each deployment

View File

@@ -0,0 +1,40 @@
# Production ML Pipeline
Objective: Get the plant disease identification ML pipeline to full production readiness with real model inference, proper class mapping, and production-grade error handling.
Status legend: [ ] todo, [~] in-progress, [x] done
## Tasks
- [ ] 01 — PlantVillage class inventory and knowledge base mapping → `01-plantvillage-class-inventory.md`
- [ ] 02 — Label mapping layer implementation → `02-label-mapping-implementation.md`
- [ ] 03 — TensorFlow.js model loading verification and fixes → `03-model-loading-verification.md`
- [ ] 04 — Confidence calibration for PlantVillage model → `04-confidence-calibration.md`
- [ ] 05 — Real model integration into identification pipeline → `05-pipeline-integration.md`
- [ ] 06 — Plant-context-aware identification → `06-plant-context-identification.md`
- [ ] 07 — End-to-end integration testing → `07-end-to-end-testing.md`
- [ ] 08 — Production hardening and observability → `08-production-hardening.md`
## Dependencies
- 01 → 02 (mapping data feeds label layer)
- 02 → 05 (labels feed pipeline)
- 03 → 05 (verified model loading feeds pipeline)
- 04 → 05 (calibration feeds pipeline)
- 05 → 06 (real model enables plant context)
- 05 → 07 (integrated pipeline enables e2e testing)
- 07 → 08 (tested pipeline enables production hardening)
## Exit Criteria
- The feature is complete when:
- Model loads successfully and produces real (non-mock) predictions
- All 38 PlantVillage classes map to valid knowledge base disease IDs
- End-to-end pipeline works: upload image → get real disease diagnoses with calibrated confidence
- Confidence scores are meaningful (high confidence for clear cases, low for ambiguous)
- Plant context optionally boosts relevant predictions
- Full integration test suite passes
- Error handling, logging, and monitoring in place
- No demo mode fallback in production
- Rate limiting and input sanitization active
- Health endpoint reports model status and inference metrics