beepboop
This commit is contained in:
2
apps/web/drizzle/0002_add-prevalence.sql
Normal file
2
apps/web/drizzle/0002_add-prevalence.sql
Normal 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`);
|
||||||
7
apps/web/drizzle/0003_giant_toad.sql
Normal file
7
apps/web/drizzle/0003_giant_toad.sql
Normal 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`);
|
||||||
347
apps/web/drizzle/meta/0002_snapshot.json
Normal file
347
apps/web/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
410
apps/web/drizzle/meta/0003_snapshot.json
Normal file
410
apps/web/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1780710023177,
|
"when": 1780710023177,
|
||||||
"tag": "0001_add-disease-images",
|
"tag": "0001_add-disease-images",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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 config (Next.js 16 default)
|
||||||
turbopack: {
|
turbopack: {
|
||||||
resolveAlias: {
|
resolveAlias: {
|
||||||
|
|||||||
1017
apps/web/package-lock.json
generated
1017
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.17.3",
|
"@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",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
3657
apps/web/scripts/.brave-progress.json
Normal file
3657
apps/web/scripts/.brave-progress.json
Normal file
File diff suppressed because it is too large
Load Diff
655
apps/web/scripts/.ddg-progress.json
Normal file
655
apps/web/scripts/.ddg-progress.json
Normal 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
|
||||||
|
}
|
||||||
@@ -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",
|
"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",
|
"source": "commons",
|
||||||
"quality": "good"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
136
apps/web/scripts/.image-review-needed.md
Normal file
136
apps/web/scripts/.image-review-needed.md
Normal 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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
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*
|
||||||
|

|
||||||
|
URL: https://imgs.search.brave.com/PF-Eqq7LSywJp8gzOgPppbHMfsXG4Ruj9zLZKkmxYRU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjMvMTIvRnVu/Z3VzLWRpc2Vhc2Uu/anBn
|
||||||
|
|
||||||
10
apps/web/scripts/.plant-image-review-needed.md
Normal file
10
apps/web/scripts/.plant-image-review-needed.md
Normal 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)
|
||||||
296
apps/web/scripts/convert-keras-to-tfjs.py
Normal file
296
apps/web/scripts/convert-keras-to-tfjs.py
Normal 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()
|
||||||
691
apps/web/scripts/expand-diseases.ts
Normal file
691
apps/web/scripts/expand-diseases.ts
Normal 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);
|
||||||
|
});
|
||||||
414
apps/web/scripts/fill-brave-images-v2.ts
Normal file
414
apps/web/scripts/fill-brave-images-v2.ts
Normal 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);
|
||||||
|
});
|
||||||
152
apps/web/scripts/fill-brave-images.ts
Normal file
152
apps/web/scripts/fill-brave-images.ts
Normal 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);
|
||||||
|
});
|
||||||
266
apps/web/scripts/fill-ddg-images.ts
Normal file
266
apps/web/scripts/fill-ddg-images.ts
Normal 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);
|
||||||
|
});
|
||||||
301
apps/web/scripts/fill-plant-images-v2.ts
Normal file
301
apps/web/scripts/fill-plant-images-v2.ts
Normal 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);
|
||||||
|
});
|
||||||
308
apps/web/scripts/fill-plant-images.ts
Normal file
308
apps/web/scripts/fill-plant-images.ts
Normal 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);
|
||||||
|
});
|
||||||
212
apps/web/scripts/fix-classifications.ts
Normal file
212
apps/web/scripts/fix-classifications.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ import { getDb, closeDb } from "../src/lib/db/index";
|
|||||||
import { diseases, plants } from "../src/lib/db/schema";
|
import { diseases, plants } from "../src/lib/db/schema";
|
||||||
import PLANTS from "./plant-list";
|
import PLANTS from "./plant-list";
|
||||||
import { GENERIC_TEMPLATES, getTemplatesForFamily, slugify } from "./disease-templates";
|
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 {
|
interface DiseaseEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +35,7 @@ interface DiseaseEntry {
|
|||||||
prevention: string[];
|
prevention: string[];
|
||||||
lookalikeIds: string[];
|
lookalikeIds: string[];
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
|
prevalence: Prevalence;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,6 @@ async function main() {
|
|||||||
|
|
||||||
// Determine how many diseases we need for this plant
|
// Determine how many diseases we need for this plant
|
||||||
const targetMin = 15; // minimum diseases per plant
|
const targetMin = 15; // minimum diseases per plant
|
||||||
const targetMax = 45; // maximum diseases per plant
|
|
||||||
|
|
||||||
// Get family-specific templates
|
// Get family-specific templates
|
||||||
const familyTemplates = getTemplatesForFamily(plant.fam);
|
const familyTemplates = getTemplatesForFamily(plant.fam);
|
||||||
@@ -128,6 +128,7 @@ async function main() {
|
|||||||
prevention: tmpl.prevention,
|
prevention: tmpl.prevention,
|
||||||
lookalikeIds: [],
|
lookalikeIds: [],
|
||||||
severity: tmpl.severity,
|
severity: tmpl.severity,
|
||||||
|
prevalence: tmpl.severity === "critical" ? "uncommon" : "common",
|
||||||
sourceUrl: "https://pddc.wisc.edu/ (UW-Madison PDDC extension factsheets)",
|
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) {
|
for (let i = 0; i < toInsert.length; i += BATCH) {
|
||||||
const chunk = toInsert.slice(i, i + BATCH);
|
const chunk = toInsert.slice(i, i + BATCH);
|
||||||
const stmts = chunk.map((d) => ({
|
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: [
|
args: [
|
||||||
d.id,
|
d.id,
|
||||||
d.plantId,
|
d.plantId,
|
||||||
@@ -216,6 +217,7 @@ async function main() {
|
|||||||
JSON.stringify(d.prevention),
|
JSON.stringify(d.prevention),
|
||||||
JSON.stringify(d.lookalikeIds),
|
JSON.stringify(d.lookalikeIds),
|
||||||
d.severity,
|
d.severity,
|
||||||
|
d.prevalence ?? "uncommon",
|
||||||
d.sourceUrl,
|
d.sourceUrl,
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async function main() {
|
|||||||
prevention: d.prevention,
|
prevention: d.prevention,
|
||||||
lookalikeIds: d.lookalikeDiseaseIds,
|
lookalikeIds: d.lookalikeDiseaseIds,
|
||||||
severity: d.severity,
|
severity: d.severity,
|
||||||
|
prevalence: d.prevalence ?? "uncommon",
|
||||||
sourceUrl: "",
|
sourceUrl: "",
|
||||||
})
|
})
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|||||||
@@ -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;
|
const IMAGENET_STD = [0.229, 0.224, 0.225] as const;
|
||||||
|
|
||||||
/** Model input size */
|
/** Model input size */
|
||||||
const MODEL_SIZE = 224;
|
const MODEL_SIZE = 160;
|
||||||
|
|
||||||
// ─── Server-side image preprocessing ─────────────────────────────────────────
|
// ─── Server-side image preprocessing ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an uploaded image and preprocess it into a Float32Array tensor
|
* 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
|
* @param imageId - The image ID from the upload endpoint
|
||||||
* @returns Float32Array tensor ready for inference
|
* @returns Float32Array tensor ready for inference
|
||||||
|
|||||||
38
apps/web/src/app/api/plants/[id]/view/route.ts
Normal file
38
apps/web/src/app/api/plants/[id]/view/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Solanum lycopersicum",
|
scientificName: "Solanum lycopersicum",
|
||||||
family: "Solanaceae",
|
family: "Solanaceae",
|
||||||
category: "vegetable",
|
category: "vegetable",
|
||||||
|
imageUrl: "https://example.com/tomato.jpg",
|
||||||
diseaseCount: 15,
|
diseaseCount: 15,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -45,6 +46,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Ocimum basilicum",
|
scientificName: "Ocimum basilicum",
|
||||||
family: "Lamiaceae",
|
family: "Lamiaceae",
|
||||||
category: "herb",
|
category: "herb",
|
||||||
|
imageUrl: "https://example.com/basil.jpg",
|
||||||
diseaseCount: 3,
|
diseaseCount: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,6 +55,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Rosa spp.",
|
scientificName: "Rosa spp.",
|
||||||
family: "Rosaceae",
|
family: "Rosaceae",
|
||||||
category: "flower",
|
category: "flower",
|
||||||
|
imageUrl: "https://example.com/rose.jpg",
|
||||||
diseaseCount: 7,
|
diseaseCount: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,6 +64,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Monstera deliciosa",
|
scientificName: "Monstera deliciosa",
|
||||||
family: "Araceae",
|
family: "Araceae",
|
||||||
category: "houseplant",
|
category: "houseplant",
|
||||||
|
imageUrl: "https://example.com/monstera.jpg",
|
||||||
diseaseCount: 5,
|
diseaseCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,6 +73,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Dracaena trifasciata",
|
scientificName: "Dracaena trifasciata",
|
||||||
family: "Asparagaceae",
|
family: "Asparagaceae",
|
||||||
category: "houseplant",
|
category: "houseplant",
|
||||||
|
imageUrl: "https://example.com/snake-plant.jpg",
|
||||||
diseaseCount: 2,
|
diseaseCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,6 +82,7 @@ const MOCK_PLANTS: PlantCardData[] = [
|
|||||||
scientificName: "Capsicum annuum",
|
scientificName: "Capsicum annuum",
|
||||||
family: "Solanaceae",
|
family: "Solanaceae",
|
||||||
category: "vegetable",
|
category: "vegetable",
|
||||||
|
imageUrl: "https://example.com/pepper.jpg",
|
||||||
diseaseCount: 9,
|
diseaseCount: 9,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import EmptyState from "@/components/EmptyState";
|
|||||||
import { PLANT_CATEGORIES } from "@/lib/constants";
|
import { PLANT_CATEGORIES } from "@/lib/constants";
|
||||||
import type { PlantCardData } from "@/components/PlantCard";
|
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 {
|
interface BrowseContentProps {
|
||||||
allPlants: PlantCardData[];
|
allPlants: PlantCardData[];
|
||||||
}
|
}
|
||||||
@@ -24,6 +32,7 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>("all");
|
const [activeCategory, setActiveCategory] = useState<Category>("all");
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||||
|
|
||||||
const filteredPlants = useMemo(() => {
|
const filteredPlants = useMemo(() => {
|
||||||
let result = allPlants;
|
let result = allPlants;
|
||||||
@@ -42,8 +51,22 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// Sort
|
||||||
}, [activeCategory, searchQuery, allPlants]);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Controls row: search + sort */}
|
||||||
<div className="relative mb-6">
|
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
<label htmlFor="browse-search" className="sr-only">
|
{/* Search bar */}
|
||||||
Search plants and diseases
|
<div className="relative flex-1">
|
||||||
</label>
|
<label htmlFor="browse-search" className="sr-only">
|
||||||
<div className="relative">
|
Search plants and diseases
|
||||||
<svg
|
</label>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div className="relative">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="18"
|
width="18"
|
||||||
@@ -103,13 +96,80 @@ export default function BrowseContent({ allPlants }: BrowseContentProps) {
|
|||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M18 6 6 18" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m6 6 12 12" />
|
<path d="m21 21-4.3-4.3" />
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Category filter chips */}
|
{/* Category filter chips */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import type { Disease, CausalAgentType, Severity } from "@/lib/types";
|
import type { Disease, CausalAgentType, Prevalence, Severity } from "@/lib/types";
|
||||||
import ImageLightbox from "@/components/ImageLightbox";
|
import ImageLightbox from "@/components/ImageLightbox";
|
||||||
|
|
||||||
// ─── Severity badge ───
|
// ─── Severity badge ───
|
||||||
@@ -79,6 +79,7 @@ function DiseaseCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<PrevalenceBadge prevalence={disease.prevalence} />
|
||||||
<TypeBadge type={disease.causalAgentType} />
|
<TypeBadge type={disease.causalAgentType} />
|
||||||
<SeverityBadge severity={disease.severity} />
|
<SeverityBadge severity={disease.severity} />
|
||||||
</div>
|
</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 ───
|
// ─── Client component wrapper ───
|
||||||
|
|
||||||
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
|
export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
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
|
// ── Filtered + sorted diseases ──
|
||||||
const images = diseases
|
|
||||||
.filter((d) => d.imageUrl)
|
const processed = useMemo(() => {
|
||||||
.map((d) => ({ src: d.imageUrl!, alt: `${d.name} symptoms` }));
|
// 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(
|
const handleImageClick = useCallback(
|
||||||
(disease: Disease) => {
|
(disease: Disease) => {
|
||||||
@@ -229,15 +419,40 @@ export default function DiseaseCards({ diseases }: { diseases: Disease[] }) {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setLightboxOpen(false), []);
|
const handleClose = useCallback(() => setLightboxOpen(false), []);
|
||||||
|
|
||||||
|
const handleSortOrderToggle = useCallback(() => {
|
||||||
|
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (diseases.length === 0) return null;
|
if (diseases.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<SearchSortBar
|
||||||
{diseases.map((disease) => (
|
searchQuery={searchQuery}
|
||||||
<DiseaseCard key={disease.id} disease={disease} onImageClick={handleImageClick} />
|
onSearchChange={setSearchQuery}
|
||||||
))}
|
sortField={sortField}
|
||||||
</div>
|
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 “{searchQuery}”.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lightboxOpen && images.length > 0 && (
|
{lightboxOpen && images.length > 0 && (
|
||||||
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
|
<ImageLightbox images={images} initialIndex={lightboxIndex} onClose={handleClose} />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getPlantWithDiseases } from "@/lib/api/diseases-db";
|
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 DiseaseCards from "./DiseaseCards";
|
||||||
|
import PlantViewTracker from "@/components/PlantViewTracker";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ plantId: string }>;
|
params: Promise<{ plantId: string }>;
|
||||||
@@ -44,7 +46,6 @@ export default async function PlantDetailPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { plant, diseases } = result;
|
const { plant, diseases } = result;
|
||||||
const emoji = getEmojiForCategory(plant.category);
|
|
||||||
const description = getPlantDescription(
|
const description = getPlantDescription(
|
||||||
plant.commonName,
|
plant.commonName,
|
||||||
plant.scientificName,
|
plant.scientificName,
|
||||||
@@ -53,107 +54,135 @@ export default async function PlantDetailPage({ params }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
<>
|
||||||
{/* Breadcrumb */}
|
<PlantViewTracker plantId={plantId} />
|
||||||
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
|
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
|
||||||
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
{/* Breadcrumb */}
|
||||||
<li>
|
<nav className="mb-6 text-sm" aria-label="Breadcrumb">
|
||||||
<Link
|
<ol className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||||
href="/"
|
<li>
|
||||||
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
<Link
|
||||||
>
|
href="/"
|
||||||
Home
|
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
||||||
</Link>
|
>
|
||||||
</li>
|
Home
|
||||||
<li aria-hidden="true">/</li>
|
</Link>
|
||||||
<li>
|
</li>
|
||||||
<Link
|
<li aria-hidden="true">/</li>
|
||||||
href="/browse"
|
<li>
|
||||||
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
<Link
|
||||||
>
|
href="/browse"
|
||||||
Browse
|
className="hover:text-leaf-green-600 dark:hover:text-leaf-green-400 transition-colors"
|
||||||
</Link>
|
>
|
||||||
</li>
|
Browse
|
||||||
<li aria-hidden="true">/</li>
|
</Link>
|
||||||
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
|
</li>
|
||||||
</ol>
|
<li aria-hidden="true">/</li>
|
||||||
</nav>
|
<li className="text-zinc-800 dark:text-zinc-200 font-medium">{plant.commonName}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Plant hero */}
|
{/* Plant hero */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
|
<div className="flex flex-col sm:flex-row sm:items-start gap-6 mb-10">
|
||||||
{/* Emoji illustration */}
|
{/* Plant image */}
|
||||||
<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">
|
<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">
|
||||||
<span className="text-6xl sm:text-7xl" role="img" aria-hidden="true">
|
{plant.imageUrl ? (
|
||||||
{emoji}
|
<Image
|
||||||
</span>
|
src={plant.imageUrl}
|
||||||
</div>
|
alt={plant.commonName}
|
||||||
|
fill
|
||||||
<div className="flex-1 min-w-0">
|
className="object-cover"
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
sizes="(min-width: 640px) 16rem, 8rem"
|
||||||
{plant.commonName}
|
unoptimized
|
||||||
</h1>
|
/>
|
||||||
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
|
) : (
|
||||||
{plant.scientificName}
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
</p>
|
<svg
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
className="w-12 h-12 text-leaf-green-300 dark:text-leaf-green-700"
|
||||||
Family: <span className="font-medium">{plant.family}</span>
|
fill="none"
|
||||||
{" · "}
|
viewBox="0 0 24 24"
|
||||||
Category: <span className="font-medium capitalize">{plant.category}</span>
|
stroke="currentColor"
|
||||||
</p>
|
strokeWidth={1.5}
|
||||||
<p className="mt-3 text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
aria-hidden="true"
|
||||||
{description}
|
>
|
||||||
</p>
|
<path
|
||||||
<div className="mt-3 flex items-start gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
strokeLinecap="round"
|
||||||
<span aria-hidden="true">💚</span>
|
strokeLinejoin="round"
|
||||||
<span>{plant.careSummary}</span>
|
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Identify disease CTA */}
|
<div className="flex-1 min-w-0">
|
||||||
<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">
|
<h1 className="text-3xl sm:text-4xl font-bold text-zinc-900 dark:text-zinc-100">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
{plant.commonName}
|
||||||
<div>
|
</h1>
|
||||||
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
<p className="text-base text-zinc-500 dark:text-zinc-400 italic mt-1">
|
||||||
🧐 Spot a problem on your {plant.commonName.toLowerCase()}?
|
{plant.scientificName}
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1">
|
|
||||||
Upload a photo for AI-powered disease identification.
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Link
|
</div>
|
||||||
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 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">
|
||||||
📸 Identify a Disease
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
</Link>
|
<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>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe("PlantCard", () => {
|
|||||||
scientificName: "Solanum lycopersicum",
|
scientificName: "Solanum lycopersicum",
|
||||||
family: "Solanaceae",
|
family: "Solanaceae",
|
||||||
category: "vegetable",
|
category: "vegetable",
|
||||||
|
imageUrl: "https://example.com/tomato.jpg",
|
||||||
diseaseCount: 2,
|
diseaseCount: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,10 +19,18 @@ describe("PlantCard", () => {
|
|||||||
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
expect(screen.getByText("Tomato")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders plant emoji (generated from category)", () => {
|
it("renders plant image", () => {
|
||||||
render(<PlantCard plant={mockPlant} />);
|
render(<PlantCard plant={mockPlant} />);
|
||||||
// Vegetable category → 🥬 emoji
|
const img = screen.getByRole("img") as HTMLImageElement;
|
||||||
expect(screen.getByText("🥬")).toBeInTheDocument();
|
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", () => {
|
it("renders plant family", () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getEmojiForCategory } from "@/lib/display-helpers";
|
|
||||||
|
|
||||||
export interface PlantCardData {
|
export interface PlantCardData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,7 +7,10 @@ export interface PlantCardData {
|
|||||||
scientificName: string;
|
scientificName: string;
|
||||||
family: string;
|
family: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
imageUrl: string;
|
||||||
diseaseCount: number;
|
diseaseCount: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
viewCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlantCardProps {
|
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.
|
* Used on the homepage featured section and browse grid.
|
||||||
*/
|
*/
|
||||||
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
|
export default function PlantCard({ plant, showDiseaseCount = true }: PlantCardProps) {
|
||||||
const emoji = getEmojiForCategory(plant.category);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/browse/${plant.id}`}
|
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"
|
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 */}
|
{/* Plant image */}
|
||||||
<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">
|
<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">
|
||||||
<span
|
{plant.imageUrl ? (
|
||||||
className="text-6xl transition-transform duration-300 group-hover:scale-110"
|
<Image
|
||||||
role="img"
|
src={plant.imageUrl}
|
||||||
aria-hidden="true"
|
alt={plant.commonName}
|
||||||
>
|
fill
|
||||||
{emoji}
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
</span>
|
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>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
26
apps/web/src/components/PlantViewTracker.tsx
Normal file
26
apps/web/src/components/PlantViewTracker.tsx
Normal 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
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { sql, eq } from "drizzle-orm";
|
import { sql, eq } from "drizzle-orm";
|
||||||
import { getDb } from "@/lib/db/index";
|
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";
|
import type { PlantCardData } from "@/components/PlantCard";
|
||||||
|
|
||||||
export type { PlantCardData };
|
export type { PlantCardData };
|
||||||
@@ -24,10 +24,14 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
|
|||||||
scientificName: plants.scientificName,
|
scientificName: plants.scientificName,
|
||||||
family: plants.family,
|
family: plants.family,
|
||||||
category: plants.category,
|
category: plants.category,
|
||||||
|
imageUrl: plants.imageUrl,
|
||||||
|
updatedAt: plants.updatedAt,
|
||||||
|
viewCount: sql<number>`COALESCE(${plantViews.viewCount}, 0)`,
|
||||||
diseaseCount: sql<number>`COUNT(${diseases.id})`,
|
diseaseCount: sql<number>`COUNT(${diseases.id})`,
|
||||||
})
|
})
|
||||||
.from(plants)
|
.from(plants)
|
||||||
.leftJoin(diseases, eq(diseases.plantId, plants.id))
|
.leftJoin(diseases, eq(diseases.plantId, plants.id))
|
||||||
|
.leftJoin(plantViews, eq(plantViews.plantId, plants.id))
|
||||||
.groupBy(plants.id)
|
.groupBy(plants.id)
|
||||||
.orderBy(plants.commonName);
|
.orderBy(plants.commonName);
|
||||||
|
|
||||||
@@ -37,6 +41,9 @@ export async function getBrowsePlants(): Promise<PlantCardData[]> {
|
|||||||
scientificName: r.scientificName,
|
scientificName: r.scientificName,
|
||||||
family: r.family,
|
family: r.family,
|
||||||
category: r.category,
|
category: r.category,
|
||||||
|
imageUrl: r.imageUrl,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
viewCount: r.viewCount,
|
||||||
diseaseCount: r.diseaseCount,
|
diseaseCount: r.diseaseCount,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -53,6 +60,7 @@ export async function getBrowsePlant(id: string): Promise<PlantCardData | null>
|
|||||||
scientificName: plants.scientificName,
|
scientificName: plants.scientificName,
|
||||||
family: plants.family,
|
family: plants.family,
|
||||||
category: plants.category,
|
category: plants.category,
|
||||||
|
imageUrl: plants.imageUrl,
|
||||||
diseaseCount: sql<number>`COUNT(${diseases.id})`,
|
diseaseCount: sql<number>`COUNT(${diseases.id})`,
|
||||||
})
|
})
|
||||||
.from(plants)
|
.from(plants)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
Plant,
|
Plant,
|
||||||
PlantListParams,
|
PlantListParams,
|
||||||
PlantWithDiseases,
|
PlantWithDiseases,
|
||||||
|
Prevalence,
|
||||||
Severity,
|
Severity,
|
||||||
PlantCategory,
|
PlantCategory,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -50,6 +51,7 @@ function toDisease(row: typeof diseases.$inferSelect): Disease {
|
|||||||
prevention: row.prevention as string[],
|
prevention: row.prevention as string[],
|
||||||
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
|
lookalikeDiseaseIds: (row.lookalikeIds as string[]) ?? [],
|
||||||
severity: row.severity as Severity,
|
severity: row.severity as Severity,
|
||||||
|
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
|
||||||
imageUrl: (row.imageUrl as string) || undefined,
|
imageUrl: (row.imageUrl as string) || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -278,6 +280,7 @@ export async function validateKnowledgeBase(): Promise<string[]> {
|
|||||||
"environmental",
|
"environmental",
|
||||||
];
|
];
|
||||||
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
||||||
|
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare"];
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -328,6 +331,11 @@ export async function validateKnowledgeBase(): Promise<string[]> {
|
|||||||
errors.push(`Disease "${d.id}" has invalid severity: ${full.severity}`);
|
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
|
// Minimum counts
|
||||||
const symptoms = full.symptoms as string[];
|
const symptoms = full.symptoms as string[];
|
||||||
const causes = full.causes as string[];
|
const causes = full.causes as string[];
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient, type InValue } from "@libsql/client";
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -173,6 +173,7 @@ export function rowToDisease(row: Record<string, unknown>): Disease {
|
|||||||
prevention: JSON.parse(row.prevention as string) as string[],
|
prevention: JSON.parse(row.prevention as string) as string[],
|
||||||
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
|
lookalikeDiseaseIds: JSON.parse(row.lookalike_ids as string) as string[],
|
||||||
severity: row.severity as Severity,
|
severity: row.severity as Severity,
|
||||||
|
prevalence: (row.prevalence as Prevalence) ?? "uncommon",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export const diseases = sqliteTable(
|
|||||||
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
treatment: text("treatment", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||||
prevention: text("prevention", { 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[]>(),
|
lookalikeIds: text("lookalike_ids", { mode: "json" }).notNull().default([]).$type<string[]>(),
|
||||||
|
prevalence: text("prevalence", {
|
||||||
|
enum: ["common", "uncommon", "rare"],
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default("uncommon"),
|
||||||
severity: text("severity", {
|
severity: text("severity", {
|
||||||
enum: ["low", "moderate", "high", "critical"],
|
enum: ["low", "moderate", "high", "critical"],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
@@ -70,6 +75,7 @@ export const diseases = sqliteTable(
|
|||||||
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
|
plantIdIdx: index("idx_diseases_plant_id").on(table.plantId),
|
||||||
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
|
causalAgentIdx: index("idx_diseases_causal_agent").on(table.causalAgentType),
|
||||||
severityIdx: index("idx_diseases_severity").on(table.severity),
|
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'))`),
|
.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 ──────────────────────────────────────────────────────
|
// ─── Relation Inference ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const plantsRelations = {};
|
export const plantsRelations = {};
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side image preprocessing pipeline.
|
* 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
|
* converts RGBA → RGB, normalizes pixel values, and produces flat
|
||||||
* Float32Array tensors ready for ML inference or base64 transmission.
|
* 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:
|
* 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_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)
|
* IMAGE_STD_R/G/B — per-channel std for normalization (default 0.229, 0.224, 0.225 — ImageNet)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
// ─── 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_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
|
const DEFAULT_STD = [0.229, 0.224, 0.225] as const; // ImageNet RGB stds
|
||||||
|
|
||||||
|
|||||||
@@ -130,12 +130,12 @@ describe("createRandomTensor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("INPUT_SHAPE and INPUT_SIZE", () => {
|
describe("INPUT_SHAPE and INPUT_SIZE", () => {
|
||||||
it("INPUT_SHAPE is [1, 3, 224, 224]", () => {
|
it("INPUT_SHAPE is [1, 3, 160, 160]", () => {
|
||||||
expect(INPUT_SHAPE).toEqual([1, 3, 224, 224]);
|
expect(INPUT_SHAPE).toEqual([1, 3, 160, 160]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("INPUT_SIZE equals 3 * 224 * 224", () => {
|
it("INPUT_SIZE equals 3 * 160 * 160", () => {
|
||||||
expect(INPUT_SIZE).toBe(3 * 224 * 224);
|
expect(INPUT_SIZE).toBe(3 * 160 * 160);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DEFAULT_TOP_K is 5", () => {
|
it("DEFAULT_TOP_K is 5", () => {
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ import { softmaxFloat32, getTopKFloat32 } from "./confidence";
|
|||||||
/** Number of top predictions to return */
|
/** Number of top predictions to return */
|
||||||
export const DEFAULT_TOP_K = 5;
|
export const DEFAULT_TOP_K = 5;
|
||||||
|
|
||||||
/** Input tensor shape: [batch=1, channels=3, height=224, width=224] */
|
/** Input tensor shape: [batch=1, channels=3, height=160, width=160] */
|
||||||
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 224, 224];
|
export const INPUT_SHAPE: [number, number, number, number] = [1, 3, 160, 160];
|
||||||
|
|
||||||
/** Expected input tensor length */
|
/** 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 ──────────────────────────────────────────────────────────
|
// ─── Main Inference ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the full inference pipeline on a preprocessed image tensor.
|
* 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)
|
* @param topK - Number of top predictions to return (default 5)
|
||||||
* @returns InferenceResult with top-K predictions and timing
|
* @returns InferenceResult with top-K predictions and timing
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
|
|||||||
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
// Reshape to [1, 3, 224, 224] NCHW → [1, 224, 224, 3] NHWC for TF.js
|
// Reshape to [1, 3, 160, 160] NCHW → [1, 160, 160, 3] NHWC for TF.js
|
||||||
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 224, 224])
|
const inputTensor = tf.tensor4d(Array.from(tensor), [3, 160, 160])
|
||||||
.transpose([1, 2, 0])
|
.transpose([1, 2, 0])
|
||||||
.expandDims(0);
|
.expandDims(0);
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ async function tryLoadTFJS(): Promise<PlantDiseaseModel | null> {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
backend: "tfjs",
|
backend: "tfjs",
|
||||||
modelId: MODEL_ID,
|
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> {
|
async predict(tensor: Float32Array): Promise<ModelOutput> {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
// ONNX expects NCHW format: [1, 3, 224, 224]
|
// ONNX expects NCHW format: [1, 3, 160, 160]
|
||||||
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 224, 224]);
|
const inputTensor = new ort.Tensor("float32", tensor, [1, 3, 160, 160]);
|
||||||
const feeds = { [session.inputNames[0]]: inputTensor };
|
const feeds = { [session.inputNames[0]]: inputTensor };
|
||||||
const results = await session.run(feeds);
|
const results = await session.run(feeds);
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ async function tryLoadONNX(): Promise<PlantDiseaseModel | null> {
|
|||||||
loaded: true,
|
loaded: true,
|
||||||
backend: "onnx",
|
backend: "onnx",
|
||||||
modelId: MODEL_ID,
|
modelId: MODEL_ID,
|
||||||
numClasses: 95,
|
numClasses: 38,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -313,7 +313,7 @@ function createMockModel(): PlantDiseaseModel {
|
|||||||
loaded: false,
|
loaded: false,
|
||||||
backend: "mock",
|
backend: "mock",
|
||||||
modelId: MODEL_ID,
|
modelId: MODEL_ID,
|
||||||
numClasses: 95,
|
numClasses: 38,
|
||||||
error: "Model files not found. Running in demo mode with mock predictions.",
|
error: "Model files not found. Running in demo mode with mock predictions.",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -326,7 +326,7 @@ function createMockModel(): PlantDiseaseModel {
|
|||||||
* reproducible but varied predictions.
|
* reproducible but varied predictions.
|
||||||
*/
|
*/
|
||||||
function generateMockLogits(tensor: Float32Array): Float32Array {
|
function generateMockLogits(tensor: Float32Array): Float32Array {
|
||||||
const numClasses = 95;
|
const numClasses = 38;
|
||||||
const logits = new Float32Array(numClasses);
|
const logits = new Float32Array(numClasses);
|
||||||
|
|
||||||
// Simple hash of input for deterministic output
|
// Simple hash of input for deterministic output
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export type CausalAgentType = "fungal" | "bacterial" | "viral" | "environmental"
|
|||||||
/** Severity levels for plant diseases */
|
/** Severity levels for plant diseases */
|
||||||
export type Severity = "low" | "moderate" | "high" | "critical";
|
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 */
|
/** Plant category for grouping and filtering */
|
||||||
export type PlantCategory =
|
export type PlantCategory =
|
||||||
| "vegetable"
|
| "vegetable"
|
||||||
@@ -69,6 +72,8 @@ export interface Disease {
|
|||||||
lookalikeDiseaseIds: string[];
|
lookalikeDiseaseIds: string[];
|
||||||
/** Overall severity of the disease */
|
/** Overall severity of the disease */
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
|
/** How common/prevalent this disease is */
|
||||||
|
prevalence: Prevalence;
|
||||||
/** URL to a representative image showing disease symptoms */
|
/** URL to a representative image showing disease symptoms */
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 0–37):
|
||||||
|
|
||||||
|
```
|
||||||
|
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 0–37 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 0–37 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
|
||||||
@@ -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 0–37 → 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
|
||||||
@@ -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.99–1.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)
|
||||||
@@ -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.0–3.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.2–1.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.0–3.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
|
||||||
279
apps/web/tasks/production-ml-pipeline/05-pipeline-integration.md
Normal file
279
apps/web/tasks/production-ml-pipeline/05-pipeline-integration.md
Normal 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
|
||||||
@@ -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 1–9 diseases, so the boost is specific enough to be useful without being overly restrictive.
|
||||||
292
apps/web/tasks/production-ml-pipeline/07-end-to-end-testing.md
Normal file
292
apps/web/tasks/production-ml-pipeline/07-end-to-end-testing.md
Normal 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
|
||||||
405
apps/web/tasks/production-ml-pipeline/08-production-hardening.md
Normal file
405
apps/web/tasks/production-ml-pipeline/08-production-hardening.md
Normal 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
|
||||||
40
apps/web/tasks/production-ml-pipeline/README.md
Normal file
40
apps/web/tasks/production-ml-pipeline/README.md
Normal 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
|
||||||
Reference in New Issue
Block a user