Compare commits
5 Commits
876c26968b
...
edfe2a3331
| Author | SHA1 | Date | |
|---|---|---|---|
| edfe2a3331 | |||
| 9f9b88c8db | |||
| bc4843fb88 | |||
| c61540fc63 | |||
| 9e6696758e |
25
apps/web/.vercelignore
Normal file
25
apps/web/.vercelignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Next.js build output (Vercel rebuilds)
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# Dependencies (Vercel installs these)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Python venv and caches
|
||||||
|
.tfjs-venv/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Data files (large ML datasets — 37G)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Git (Vercel prefers no git dir for CLI deploys)
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Scripts and task files (build-time only)
|
||||||
|
scripts/
|
||||||
|
tasks/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
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
@@ -9,6 +9,13 @@
|
|||||||
* Each run scans the directory, reports deficits, then fills them.
|
* Each run scans the directory, reports deficits, then fills them.
|
||||||
* Interrupt-safe: re-run to pick up where you left off.
|
* Interrupt-safe: re-run to pick up where you left off.
|
||||||
*
|
*
|
||||||
|
* Parallelism strategy:
|
||||||
|
* - Disease-level: 30 diseases processed concurrently
|
||||||
|
* - Per disease: all 3 DDG queries run in parallel
|
||||||
|
* - Per query: all search pages fetched in parallel
|
||||||
|
* - Per disease: DDG, iNaturalist, and Wikimedia Commons all run concurrently
|
||||||
|
* - A shared DDG token-bucket rate limiter prevents bans
|
||||||
|
*
|
||||||
* Usage: cd apps/web && npx tsx scripts/fill-training-dataset.ts
|
* Usage: cd apps/web && npx tsx scripts/fill-training-dataset.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -35,7 +42,6 @@ try {
|
|||||||
|
|
||||||
import { getDb, closeDb } from "@/lib/db/index";
|
import { getDb, closeDb } from "@/lib/db/index";
|
||||||
import { diseases } from "@/lib/db/schema";
|
import { diseases } from "@/lib/db/schema";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
|
|
||||||
// ─── Config ─────────────────────────────────────────────────────────────────
|
// ─── Config ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,14 +54,24 @@ const TARGET_PER_DISEASE = 200;
|
|||||||
/** Target images for the "healthy" class */
|
/** Target images for the "healthy" class */
|
||||||
const TARGET_HEALTHY = 400;
|
const TARGET_HEALTHY = 400;
|
||||||
|
|
||||||
/** Delay between DuckDuckGo search API calls (ms) */
|
/**
|
||||||
const SEARCH_DELAY = 1500;
|
* How many diseases to process in parallel.
|
||||||
|
* Each disease is I/O-bound (HTTP requests), so high concurrency is safe.
|
||||||
|
* The global DDG rate limiter prevents us from overwhelming DuckDuckGo.
|
||||||
|
*/
|
||||||
|
const DISEASE_CONCURRENCY = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max DDG requests per second (shared across all concurrent diseases).
|
||||||
|
* DuckDuckGo is fairly tolerant, but we still want to be polite.
|
||||||
|
* With DISEASE_CONCURRENCY=30, each disease fires 3 parallel queries with
|
||||||
|
* parallel pages = 9 parallel DDG requests per disease at peak.
|
||||||
|
* The rate limiter serializes this so we don't get banned.
|
||||||
|
*/
|
||||||
|
const DDG_RATE_LIMIT_RPS = 3;
|
||||||
|
|
||||||
/** Max concurrent image downloads per disease */
|
/** Max concurrent image downloads per disease */
|
||||||
const CONCURRENT_DOWNLOADS = 30;
|
const CONCURRENT_DOWNLOADS = 3;
|
||||||
|
|
||||||
/** Number of diseases to process in parallel */
|
|
||||||
const DISEASE_CONCURRENCY = 5;
|
|
||||||
|
|
||||||
/** Minimum image size in bytes to accept */
|
/** Minimum image size in bytes to accept */
|
||||||
const MIN_IMAGE_SIZE = 10_000; // 10KB
|
const MIN_IMAGE_SIZE = 10_000; // 10KB
|
||||||
@@ -68,11 +84,22 @@ const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
|
|||||||
|
|
||||||
/** User agent for requests */
|
/** User agent for requests */
|
||||||
const UA =
|
const UA =
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1";
|
||||||
|
|
||||||
/** Healthy class directory name */
|
/** Healthy class directory name */
|
||||||
const HEALTHY_CLASS = "healthy";
|
const HEALTHY_CLASS = "healthy";
|
||||||
|
|
||||||
|
/** How often (in diseases processed) to flush the seen-URLs cache to disk */
|
||||||
|
const SEEN_CACHE_FLUSH_INTERVAL = 20;
|
||||||
|
|
||||||
|
/** Max DDG pages to fetch per query.
|
||||||
|
* Each page returns ~100 image results, so 3 pages × 3 queries = ~900 raw URLs
|
||||||
|
* before dedup — more than enough to find 200 unique, valid images. */
|
||||||
|
const MAX_DDG_PAGES = 3;
|
||||||
|
|
||||||
|
/** Healthy source queries limit */
|
||||||
|
const MAX_HEALTHY_QUERIES = 20;
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface DuckDuckGoImageResult {
|
interface DuckDuckGoImageResult {
|
||||||
@@ -92,6 +119,53 @@ interface DiseaseInfo {
|
|||||||
needed: number;
|
needed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CollectResult {
|
||||||
|
urls: string[];
|
||||||
|
exhausted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token-Bucket Rate Limiter ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TokenBucket {
|
||||||
|
private tokens: number;
|
||||||
|
private lastRefill: number;
|
||||||
|
private readonly capacity: number;
|
||||||
|
private readonly refillInterval: number; // ms per token (e.g., 100ms for 10 rps)
|
||||||
|
|
||||||
|
constructor(rps: number) {
|
||||||
|
this.capacity = rps;
|
||||||
|
this.tokens = rps;
|
||||||
|
this.lastRefill = Date.now();
|
||||||
|
this.refillInterval = 1000 / rps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acquire one token, blocking until one is available. */
|
||||||
|
async acquire(): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
this.refill();
|
||||||
|
if (this.tokens >= 1) {
|
||||||
|
this.tokens -= 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No tokens — wait for the next one to arrive, then retry
|
||||||
|
await sleep(Math.ceil(this.refillInterval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refill(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastRefill;
|
||||||
|
const newTokens = Math.floor(elapsed / this.refillInterval);
|
||||||
|
if (newTokens > 0) {
|
||||||
|
this.tokens = Math.min(this.capacity, this.tokens + newTokens);
|
||||||
|
this.lastRefill = now - (elapsed % this.refillInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global DDG rate limiter — all concurrent diseases share this
|
||||||
|
const ddgLimiter = new TokenBucket(DDG_RATE_LIMIT_RPS);
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
@@ -109,13 +183,6 @@ function countImagesInDir(dir: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format bytes for display */
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Seen-URLs Cache ──────────────────────────────────────────────────────
|
// ─── Seen-URLs Cache ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,9 +205,38 @@ function saveSeenUrlsCache(cache: Record<string, string[]>): void {
|
|||||||
writeFileSync(SEEN_CACHE_FILE, JSON.stringify(cache, null, 2));
|
writeFileSync(SEEN_CACHE_FILE, JSON.stringify(cache, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DDG VQD Token Cache ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory cache for DDG VQD tokens.
|
||||||
|
* Tokens are per-query, but if we've fetched one for a similar query recently,
|
||||||
|
* we can skip the initial HTML page fetch.
|
||||||
|
*/
|
||||||
|
const vqdCache = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
function getCachedVqd(query: string): string | undefined {
|
||||||
|
const entry = vqdCache.get(query);
|
||||||
|
if (entry && entry.expiresAt > Date.now()) return entry.token;
|
||||||
|
vqdCache.delete(query);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedVqd(query: string, token: string): void {
|
||||||
|
// VQD tokens seem to be valid for a few minutes; cache for 5 min
|
||||||
|
vqdCache.set(query, { token, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||||
|
// Evict oldest entries if cache grows too large (unlikely but safe)
|
||||||
|
if (vqdCache.size > 500) {
|
||||||
|
const firstKey = vqdCache.keys().next().value;
|
||||||
|
if (firstKey) vqdCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DuckDuckGo API ─────────────────────────────────────────────────────────
|
// ─── DuckDuckGo API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getVqdToken(query: string): Promise<string> {
|
async function getVqdToken(query: string): Promise<string> {
|
||||||
|
const cached = getCachedVqd(query);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`;
|
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&t=h_&iax=images&ia=images`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -154,6 +250,7 @@ async function getVqdToken(query: string): Promise<string> {
|
|||||||
const match = html.match(/vqd['"]?\s*[:=]\s*['"]([a-f0-9-]+)['"]/);
|
const match = html.match(/vqd['"]?\s*[:=]\s*['"]([a-f0-9-]+)['"]/);
|
||||||
if (!match) throw new Error(`Could not extract vqd token for "${query}"`);
|
if (!match) throw new Error(`Could not extract vqd token for "${query}"`);
|
||||||
|
|
||||||
|
setCachedVqd(query, match[1]);
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +259,9 @@ async function searchImagesDuckDuckGo(
|
|||||||
vqd: string,
|
vqd: string,
|
||||||
page: number,
|
page: number,
|
||||||
): Promise<DuckDuckGoImageResult[]> {
|
): Promise<DuckDuckGoImageResult[]> {
|
||||||
|
// Rate-limit before making the request
|
||||||
|
await ddgLimiter.acquire();
|
||||||
|
|
||||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(
|
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(
|
||||||
query,
|
query,
|
||||||
)}&vqd=${vqd}&o=json&p=${page}&f=,,,`;
|
)}&vqd=${vqd}&o=json&p=${page}&f=,,,`;
|
||||||
@@ -177,27 +277,29 @@ async function searchImagesDuckDuckGo(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
console.warn(" ⚠ DDG rate limited (429). Waiting 10s...");
|
// Rate limited — wait and retry once
|
||||||
await sleep(10_000);
|
await sleep(5_000);
|
||||||
return searchImagesDuckDuckGo(query, vqd, page);
|
return searchImagesDuckDuckGo(query, vqd, page);
|
||||||
}
|
}
|
||||||
if (res.status === 403) return [];
|
if (res.status === 403) return [];
|
||||||
throw new Error(`DuckDuckGo search failed: ${res.status}`);
|
// Don't throw for transient errors — just return empty
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as { results: DuckDuckGoImageResult[] };
|
const data = (await res.json()) as { results: DuckDuckGoImageResult[] };
|
||||||
return data.results ?? [];
|
return data.results ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectImagesDuckDuckGo(
|
/**
|
||||||
|
* Collect images from DDG for a single query.
|
||||||
|
* Fetches up to MAX_DDG_PAGES pages in PARALLEL (rate-limited via ddgLimiter).
|
||||||
|
*/
|
||||||
|
async function collectFromDdgQuery(
|
||||||
query: string,
|
query: string,
|
||||||
target: number,
|
target: number,
|
||||||
seenUrls: Set<string>,
|
seenUrls: Set<string>,
|
||||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
): Promise<CollectResult> {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
let page = 1;
|
|
||||||
let exhausted = false;
|
|
||||||
let consecutiveEmpty = 0;
|
|
||||||
|
|
||||||
let vqd: string;
|
let vqd: string;
|
||||||
try {
|
try {
|
||||||
@@ -207,34 +309,19 @@ async function collectImagesDuckDuckGo(
|
|||||||
return { urls: [], exhausted: true };
|
return { urls: [], exhausted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PAGES = 5;
|
// Fetch all pages in parallel
|
||||||
let lowNoveltyCount = 0;
|
const pageFetches: Promise<DuckDuckGoImageResult[]>[] = [];
|
||||||
|
for (let page = 1; page <= MAX_DDG_PAGES; page++) {
|
||||||
|
pageFetches.push(searchImagesDuckDuckGo(query, vqd, page));
|
||||||
|
}
|
||||||
|
|
||||||
while (results.length < target && page <= MAX_PAGES) {
|
const pageResults = await Promise.allSettled(pageFetches);
|
||||||
await sleep(SEARCH_DELAY);
|
|
||||||
|
|
||||||
let pageResults: DuckDuckGoImageResult[];
|
for (const settled of pageResults) {
|
||||||
try {
|
if (settled.status !== "fulfilled") continue;
|
||||||
pageResults = await searchImagesDuckDuckGo(query, vqd, page);
|
if (results.length >= target) break;
|
||||||
} catch (err) {
|
|
||||||
console.warn(` ⚠ DDG error: ${err instanceof Error ? err.message : "unknown"}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pageResults || pageResults.length === 0) {
|
for (const r of settled.value) {
|
||||||
consecutiveEmpty++;
|
|
||||||
if (consecutiveEmpty >= 3) {
|
|
||||||
exhausted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
page++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
consecutiveEmpty = 0;
|
|
||||||
let newCount = 0;
|
|
||||||
|
|
||||||
for (const r of pageResults) {
|
|
||||||
if (results.length >= target) break;
|
if (results.length >= target) break;
|
||||||
const imgUrl = r.image || r.url;
|
const imgUrl = r.image || r.url;
|
||||||
if (!imgUrl || typeof imgUrl !== "string") continue;
|
if (!imgUrl || typeof imgUrl !== "string") continue;
|
||||||
@@ -246,21 +333,36 @@ async function collectImagesDuckDuckGo(
|
|||||||
}
|
}
|
||||||
seenUrls.add(imgUrl);
|
seenUrls.add(imgUrl);
|
||||||
results.push(imgUrl);
|
results.push(imgUrl);
|
||||||
newCount++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRatio = newCount / pageResults.length;
|
|
||||||
if (newRatio < 0.05) {
|
|
||||||
lowNoveltyCount++;
|
|
||||||
if (lowNoveltyCount >= 2) break;
|
|
||||||
} else {
|
|
||||||
lowNoveltyCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length < target) page++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { urls: results.slice(0, target), exhausted };
|
return { urls: results.slice(0, target), exhausted: results.length < target };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect images from DDG across ALL queries for a disease.
|
||||||
|
* Runs all queries in PARALLEL, then merges deduplicated results.
|
||||||
|
*/
|
||||||
|
async function collectImagesDuckDuckGo(
|
||||||
|
queries: string[],
|
||||||
|
target: number,
|
||||||
|
seenUrls: Set<string>,
|
||||||
|
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||||
|
// Run all queries in parallel
|
||||||
|
const queryResults = await Promise.allSettled(
|
||||||
|
queries.map((q) => collectFromDdgQuery(q, target, seenUrls)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge results — seenUrls already deduplicates across queries
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (const settled of queryResults) {
|
||||||
|
if (settled.status === "fulfilled") {
|
||||||
|
merged.push(...settled.value.urls);
|
||||||
|
if (merged.length >= target) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { urls: merged.slice(0, target), exhausted: merged.length < target };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── iNaturalist API ───────────────────────────────────────────────────────
|
// ─── iNaturalist API ───────────────────────────────────────────────────────
|
||||||
@@ -269,7 +371,7 @@ async function searchImagesInaturalist(
|
|||||||
query: string,
|
query: string,
|
||||||
target: number,
|
target: number,
|
||||||
seenUrls: Set<string>,
|
seenUrls: Set<string>,
|
||||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
): Promise<CollectResult> {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
const perPage = Math.min(target, 200);
|
const perPage = Math.min(target, 200);
|
||||||
|
|
||||||
@@ -316,7 +418,7 @@ async function searchImagesCommons(
|
|||||||
query: string,
|
query: string,
|
||||||
target: number,
|
target: number,
|
||||||
seenUrls: Set<string>,
|
seenUrls: Set<string>,
|
||||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
): Promise<CollectResult> {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
let sroffset = 0;
|
let sroffset = 0;
|
||||||
|
|
||||||
@@ -374,7 +476,7 @@ async function downloadImage(url: string, destPath: string): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg,*/*" },
|
headers: { "User-Agent": UA, Accept: "image/webp,image/png,image/jpeg,*/*" },
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(8_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
|
|
||||||
@@ -426,13 +528,7 @@ async function downloadBatch(
|
|||||||
if (r.success) downloaded++;
|
if (r.success) downloaded++;
|
||||||
else failed++;
|
else failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = downloaded + failed;
|
|
||||||
if (total % 30 === 0 || total === urls.length) {
|
|
||||||
process.stdout.write(`\r Progress: ${downloaded}/${urls.length} (${failed} failed)`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log();
|
|
||||||
|
|
||||||
return { downloaded, failed, lastIndex: index };
|
return { downloaded, failed, lastIndex: index };
|
||||||
}
|
}
|
||||||
@@ -457,10 +553,14 @@ function buildHealthyQueries(plant: string): string[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to collect up to `needed` images for a disease by hitting all three
|
* Try to collect up to `needed` images for a disease by hitting all three
|
||||||
* sources in order. Returns how many new images were actually downloaded.
|
* sources IN PARALLEL. Returns how many new images were actually downloaded.
|
||||||
|
*
|
||||||
|
* Sources (DDG with its 3 internal queries, iNat, Commons) all run concurrently.
|
||||||
|
* As soon as any source completes, its URLs are downloaded immediately while
|
||||||
|
* other sources are still searching (pipeline).
|
||||||
*/
|
*/
|
||||||
async function fillClass(
|
async function fillClass(
|
||||||
diseaseId: string,
|
_diseaseId: string,
|
||||||
queries: string[],
|
queries: string[],
|
||||||
needed: number,
|
needed: number,
|
||||||
classDir: string,
|
classDir: string,
|
||||||
@@ -469,51 +569,63 @@ async function fillClass(
|
|||||||
if (needed <= 0) return 0;
|
if (needed <= 0) return 0;
|
||||||
|
|
||||||
mkdirSync(classDir, { recursive: true });
|
mkdirSync(classDir, { recursive: true });
|
||||||
|
const startCount = countImagesInDir(classDir);
|
||||||
|
|
||||||
const allUrls: string[] = [];
|
// ── Run all sources in parallel, pipelining downloads ──────────────────
|
||||||
|
// Start downloading from each source as soon as it returns results, rather
|
||||||
|
// than waiting for all sources to complete. DDG is (by far) the richest
|
||||||
|
// source, so its results start saving to disk while iNat and Commons are
|
||||||
|
// still searching.
|
||||||
|
//
|
||||||
|
// Each source gets a DEDICATED index range so there's no race condition
|
||||||
|
// writing files. DDG gets [startCount, startCount+199], iNat gets
|
||||||
|
// [startCount+200, startCount+399], Commons gets [startCount+400,...].
|
||||||
|
// The 4-digit filename supports up to 9999, well beyond our 200 target.
|
||||||
|
|
||||||
// ── Source 1: DuckDuckGo ───────────────────────────────────────────────
|
let totalDownloaded = 0;
|
||||||
if (allUrls.length < needed) {
|
let totalFailed = 0;
|
||||||
for (const query of queries) {
|
let anySuccess = false;
|
||||||
if (allUrls.length >= needed) break;
|
|
||||||
process.stdout.write(` DDG: "${query.substring(0, 40)}"... `);
|
|
||||||
const result = await collectImagesDuckDuckGo(query, needed - allUrls.length, seenUrls);
|
|
||||||
allUrls.push(...result.urls);
|
|
||||||
console.log(`${result.urls.length} new`);
|
|
||||||
if (result.exhausted) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Source 2: iNaturalist ──────────────────────────────────────────────
|
const collectAndDownload = async (
|
||||||
if (allUrls.length < needed) {
|
label: string,
|
||||||
process.stdout.write(` iNat: Searching... `);
|
collector: () => Promise<CollectResult>,
|
||||||
const result = await searchImagesInaturalist(queries[0], needed - allUrls.length, seenUrls);
|
indexOffset: number,
|
||||||
allUrls.push(...result.urls);
|
): Promise<void> => {
|
||||||
console.log(`${result.urls.length} new`);
|
const result = await collector();
|
||||||
}
|
if (result.urls.length === 0) return;
|
||||||
|
console.log(` ${label}: ${result.urls.length} new URLs`);
|
||||||
|
|
||||||
// ── Source 3: Wikimedia Commons ────────────────────────────────────────
|
// Each source writes to its own non-overlapping range
|
||||||
if (allUrls.length < needed) {
|
const { downloaded, failed } = await downloadBatch(result.urls, classDir, indexOffset);
|
||||||
process.stdout.write(` Commons: Searching... `);
|
totalDownloaded += downloaded;
|
||||||
const result = await searchImagesCommons(queries[0], needed - allUrls.length, seenUrls);
|
totalFailed += failed;
|
||||||
allUrls.push(...result.urls);
|
if (downloaded > 0) anySuccess = true;
|
||||||
console.log(`${result.urls.length} new`);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
if (allUrls.length === 0) {
|
await Promise.allSettled([
|
||||||
|
collectAndDownload("DDG", () => collectImagesDuckDuckGo(queries, needed, seenUrls), startCount),
|
||||||
|
collectAndDownload(
|
||||||
|
"iNat",
|
||||||
|
() => searchImagesInaturalist(queries[0], needed, seenUrls),
|
||||||
|
startCount + TARGET_PER_DISEASE,
|
||||||
|
),
|
||||||
|
collectAndDownload(
|
||||||
|
"Commons",
|
||||||
|
() => searchImagesCommons(queries[0], needed, seenUrls),
|
||||||
|
startCount + 2 * TARGET_PER_DISEASE,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!anySuccess) {
|
||||||
console.log(` ✗ No new images found from any source`);
|
console.log(` ✗ No new images found from any source`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` Downloading ${allUrls.length} images...`);
|
|
||||||
const startIndex = countImagesInDir(classDir);
|
|
||||||
const { downloaded, failed } = await downloadBatch(allUrls, classDir, startIndex);
|
|
||||||
|
|
||||||
const newTotal = countImagesInDir(classDir);
|
const newTotal = countImagesInDir(classDir);
|
||||||
const gained = newTotal - startIndex;
|
const gained = newTotal - startCount;
|
||||||
console.log(
|
console.log(
|
||||||
` ${downloaded > 0 ? "✓" : "✗"} Downloaded ${downloaded}/${allUrls.length}` +
|
` ✓ ${totalDownloaded}/${totalDownloaded + totalFailed} downloaded` +
|
||||||
` (${failed} failed, ${gained} new files)`,
|
` (${totalFailed} failed, ${gained} new files)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return gained;
|
return gained;
|
||||||
@@ -555,11 +667,23 @@ function scanDataset(): ScanResult {
|
|||||||
return { diseaseCounts, healthyCount };
|
return { diseaseCounts, healthyCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CLI Flags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseFlags(): { reverse: boolean } {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
return {
|
||||||
|
reverse: args.includes("--reverse") || args.includes("-r"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const flags = parseFlags();
|
||||||
|
|
||||||
console.log("=".repeat(60));
|
console.log("=".repeat(60));
|
||||||
console.log("TRAINING DATASET FILL — Gap-filling download");
|
console.log("TRAINING DATASET FILL — Parallelized gap-filling download");
|
||||||
|
if (flags.reverse) console.log(" (reverse order — processing from lowest deficit first)");
|
||||||
console.log("=".repeat(60));
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
// Ensure dataset directory exists
|
// Ensure dataset directory exists
|
||||||
@@ -605,6 +729,10 @@ async function main() {
|
|||||||
// Sort by deficit size (largest first) so we prioritize the neediest diseases
|
// Sort by deficit size (largest first) so we prioritize the neediest diseases
|
||||||
deficits.sort((a, b) => b.needed - a.needed);
|
deficits.sort((a, b) => b.needed - a.needed);
|
||||||
|
|
||||||
|
// Reverse order if --reverse/-r flag is set (useful to try a different
|
||||||
|
// direction when the front of the queue keeps hitting dead URLs)
|
||||||
|
if (flags.reverse) deficits.reverse();
|
||||||
|
|
||||||
const healthyDeficit = TARGET_HEALTHY - healthyCount;
|
const healthyDeficit = TARGET_HEALTHY - healthyCount;
|
||||||
|
|
||||||
console.log(`\n${"=".repeat(60)}`);
|
console.log(`\n${"=".repeat(60)}`);
|
||||||
@@ -613,6 +741,11 @@ async function main() {
|
|||||||
console.log(` Diseases needing images: ${deficits.length}/${diseaseInfo.size}`);
|
console.log(` Diseases needing images: ${deficits.length}/${diseaseInfo.size}`);
|
||||||
console.log(` Total images missing: ${deficits.reduce((s, d) => s + d.needed, 0)}`);
|
console.log(` Total images missing: ${deficits.reduce((s, d) => s + d.needed, 0)}`);
|
||||||
console.log(` Healthy deficit: ${Math.max(0, healthyDeficit)}`);
|
console.log(` Healthy deficit: ${Math.max(0, healthyDeficit)}`);
|
||||||
|
console.log(` Parallelism: ${DISEASE_CONCURRENCY} diseases at once`);
|
||||||
|
console.log(` DDG rate limit: ${DDG_RATE_LIMIT_RPS} req/s (shared)`);
|
||||||
|
console.log(
|
||||||
|
` Order: ${flags.reverse ? "reverse (--reverse)" : "normal (deficit-first)"}`,
|
||||||
|
);
|
||||||
console.log(`${"=".repeat(60)}`);
|
console.log(`${"=".repeat(60)}`);
|
||||||
|
|
||||||
if (deficits.length === 0 && healthyDeficit <= 0) {
|
if (deficits.length === 0 && healthyDeficit <= 0) {
|
||||||
@@ -625,6 +758,7 @@ async function main() {
|
|||||||
const seenUrlsCache = loadSeenUrlsCache();
|
const seenUrlsCache = loadSeenUrlsCache();
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
let totalFailed = 0;
|
let totalFailed = 0;
|
||||||
|
let diseasesProcessed = 0;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// ── Step 5: Fill disease deficits ───────────────────────────────────────
|
// ── Step 5: Fill disease deficits ───────────────────────────────────────
|
||||||
@@ -641,33 +775,62 @@ async function main() {
|
|||||||
|
|
||||||
console.log(`\n[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} diseases...`);
|
console.log(`\n[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} diseases...`);
|
||||||
|
|
||||||
await Promise.all(
|
// Stagger disease starts within a batch to smooth out DDG rate limiter load.
|
||||||
batch.map(async (d) => {
|
// Without staggering, 30 diseases × 9 parallel DDG requests = 270 simultaneous
|
||||||
const classDir = resolve(DATASET_DIR, d.id);
|
// acquire() calls queue behind the rate limiter, giving the first disease a huge
|
||||||
const queries = buildSearchQueries(d.name, d.plantId);
|
// head start and the last disease a long tail. Staggering by 200ms each spreads
|
||||||
const seen = new Set<string>(seenUrlsCache[d.id] ?? []);
|
// the load evenly, reducing tail latency and improving overall throughput.
|
||||||
|
const STAGGER_MS = 200;
|
||||||
|
const batchResults = await Promise.allSettled(
|
||||||
|
batch.map((d, idx) =>
|
||||||
|
(async () => {
|
||||||
|
if (idx > 0) await sleep(idx * STAGGER_MS);
|
||||||
|
|
||||||
console.log(
|
const classDir = resolve(DATASET_DIR, d.id);
|
||||||
` [${d.id}] have ${d.have}, need ${d.needed} more` + ` (${d.name} / ${d.plantId})`,
|
const queries = buildSearchQueries(d.name, d.plantId);
|
||||||
);
|
const seen = new Set<string>(seenUrlsCache[d.id] ?? []);
|
||||||
|
|
||||||
const gained = await fillClass(d.id, queries, d.needed, classDir, seen);
|
console.log(
|
||||||
|
` [${d.id}] have ${d.have}, need ${d.needed} more` + ` (${d.name} / ${d.plantId})`,
|
||||||
|
);
|
||||||
|
|
||||||
// Update seen-URLs cache for this disease
|
const gained = await fillClass(d.id, queries, d.needed, classDir, seen);
|
||||||
seenUrlsCache[d.id] = Array.from(seen);
|
|
||||||
saveSeenUrlsCache(seenUrlsCache);
|
|
||||||
|
|
||||||
totalDownloaded += gained;
|
// Update seen-URLs cache for this disease
|
||||||
}),
|
seenUrlsCache[d.id] = Array.from(seen);
|
||||||
|
return gained;
|
||||||
|
})(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save seen cache after every batch
|
// Aggregate batch results
|
||||||
saveSeenUrlsCache(seenUrlsCache);
|
for (const result of batchResults) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
totalDownloaded += result.value;
|
||||||
|
} else {
|
||||||
|
console.error(` ✗ Disease failed: ${result.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diseasesProcessed += batch.length;
|
||||||
|
|
||||||
|
// Flush seen-URLs cache to disk periodically (not after every disease)
|
||||||
|
if (
|
||||||
|
diseasesProcessed % SEEN_CACHE_FLUSH_INTERVAL < batch.length ||
|
||||||
|
i + batch.length >= deficits.length
|
||||||
|
) {
|
||||||
|
saveSeenUrlsCache(seenUrlsCache);
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
const rate = diseasesProcessed / Math.max(1, elapsed);
|
||||||
|
const remaining = deficits.length - diseasesProcessed;
|
||||||
|
const eta = remaining / Math.max(0.01, rate);
|
||||||
console.log(
|
console.log(
|
||||||
` [Batch ${batchNum}/${totalBatches}] checkpoint — ` +
|
` [Batch ${batchNum}/${totalBatches}] checkpoint — ` +
|
||||||
`${totalDownloaded} downloaded so far (${elapsed}s elapsed)`,
|
`${totalDownloaded} downloaded, ` +
|
||||||
|
`${diseasesProcessed}/${deficits.length} diseases (${rate.toFixed(1)}/s, ` +
|
||||||
|
`ETA: ${Math.round(eta)}s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,26 +853,22 @@ async function main() {
|
|||||||
|
|
||||||
const healthySeen = new Set<string>(seenUrlsCache[HEALTHY_CLASS] ?? []);
|
const healthySeen = new Set<string>(seenUrlsCache[HEALTHY_CLASS] ?? []);
|
||||||
const healthyNeeded = TARGET_HEALTHY - countImagesInDir(healthyDir);
|
const healthyNeeded = TARGET_HEALTHY - countImagesInDir(healthyDir);
|
||||||
|
|
||||||
|
// Run all 3 sources in parallel for the healthy class too
|
||||||
|
const [ddgUrls, inatUrls, commonsUrls] = await Promise.allSettled([
|
||||||
|
collectImagesDuckDuckGo(
|
||||||
|
allHealthyQueries.slice(0, MAX_HEALTHY_QUERIES),
|
||||||
|
healthyNeeded,
|
||||||
|
healthySeen,
|
||||||
|
),
|
||||||
|
searchImagesInaturalist(allHealthyQueries[0], healthyNeeded, healthySeen),
|
||||||
|
searchImagesCommons(allHealthyQueries[0], healthyNeeded, healthySeen),
|
||||||
|
]);
|
||||||
|
|
||||||
const allUrls: string[] = [];
|
const allUrls: string[] = [];
|
||||||
|
for (const settled of [ddgUrls, inatUrls, commonsUrls]) {
|
||||||
// Try each source with up to 20 healthy queries
|
if (settled.status === "fulfilled") {
|
||||||
const sources = [
|
allUrls.push(...settled.value.urls);
|
||||||
{ name: "DDG", collector: collectImagesDuckDuckGo },
|
|
||||||
{ name: "iNat", collector: searchImagesInaturalist },
|
|
||||||
{ name: "Commons", collector: searchImagesCommons },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (allUrls.length >= healthyNeeded) break;
|
|
||||||
console.log(`\n Source: ${source.name}`);
|
|
||||||
|
|
||||||
for (const query of allHealthyQueries.slice(0, 20)) {
|
|
||||||
if (allUrls.length >= healthyNeeded) break;
|
|
||||||
|
|
||||||
process.stdout.write(` "${query}"... `);
|
|
||||||
const result = await source.collector(query, healthyNeeded - allUrls.length, healthySeen);
|
|
||||||
allUrls.push(...result.urls);
|
|
||||||
console.log(`${result.urls.length} new`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +922,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("\nFatal error:", err);
|
console.error("\nFatal error:", `\n${err}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,101 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
/**
|
||||||
|
* Data integrity tests for the plant disease knowledge base.
|
||||||
|
*
|
||||||
|
* These tests validate the seed data directly from the JSON source files.
|
||||||
|
* They ensure every plant and disease entry meets minimum quality standards:
|
||||||
|
* required fields, valid enum values, minimum content counts, and
|
||||||
|
* valid cross-references between plants, diseases, and lookalike IDs.
|
||||||
|
*
|
||||||
|
* The JSON seed data is what populates the Turso/libSQL database.
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import { describe, it, expect } from "vitest";
|
||||||
getPlantById,
|
import type { CausalAgentType, Disease, Plant, Severity, Prevalence } from "@/lib/types";
|
||||||
getDiseaseById,
|
|
||||||
getDiseasesByPlantId,
|
// Import seed data directly for validation
|
||||||
getPlantWithDiseases,
|
import rawPlants from "@/data/plants.json";
|
||||||
getDiseaseWithPlant,
|
import rawDiseases from "@/data/diseases.json";
|
||||||
getLookalikeDiseases,
|
|
||||||
searchPlants,
|
const plants = rawPlants as Plant[];
|
||||||
searchDiseases,
|
const diseases = rawDiseases as Disease[];
|
||||||
listPlants,
|
|
||||||
listDiseases,
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
validateKnowledgeBase,
|
|
||||||
plants,
|
function validateKnowledgeBase(): string[] {
|
||||||
diseases,
|
const errors: string[] = [];
|
||||||
} from "@/lib/api/diseases";
|
const validCausalAgentTypes: CausalAgentType[] = [
|
||||||
|
"fungal",
|
||||||
|
"bacterial",
|
||||||
|
"viral",
|
||||||
|
"environmental",
|
||||||
|
];
|
||||||
|
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
||||||
|
|
||||||
|
const plantIds = new Set(plants.map((p) => p.id));
|
||||||
|
const diseaseIds = new Set(diseases.map((d) => d.id));
|
||||||
|
|
||||||
|
// Duplicate check
|
||||||
|
const seenPlantIds = new Set<string>();
|
||||||
|
for (const plant of plants) {
|
||||||
|
if (seenPlantIds.has(plant.id)) {
|
||||||
|
errors.push(`Duplicate plant ID: ${plant.id}`);
|
||||||
|
}
|
||||||
|
seenPlantIds.add(plant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenDiseaseIds = new Set<string>();
|
||||||
|
for (const disease of diseases) {
|
||||||
|
if (seenDiseaseIds.has(disease.id)) {
|
||||||
|
errors.push(`Duplicate disease ID: ${disease.id}`);
|
||||||
|
}
|
||||||
|
seenDiseaseIds.add(disease.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of diseases) {
|
||||||
|
if (!plantIds.has(d.plantId)) {
|
||||||
|
errors.push(`Disease "${d.id}" references unknown plant ID: ${d.plantId}`);
|
||||||
|
}
|
||||||
|
if (!validCausalAgentTypes.includes(d.causalAgentType)) {
|
||||||
|
errors.push(`Disease "${d.id}" has invalid causalAgentType: ${d.causalAgentType}`);
|
||||||
|
}
|
||||||
|
if (!validSeverities.includes(d.severity)) {
|
||||||
|
errors.push(`Disease "${d.id}" has invalid severity: ${d.severity}`);
|
||||||
|
}
|
||||||
|
if (d.symptoms.length < 3) {
|
||||||
|
errors.push(`Disease "${d.id}" has fewer than 3 symptoms (${d.symptoms.length})`);
|
||||||
|
}
|
||||||
|
if (d.causes.length < 2) {
|
||||||
|
errors.push(`Disease "${d.id}" has fewer than 2 causes (${d.causes.length})`);
|
||||||
|
}
|
||||||
|
if (d.treatment.length < 3) {
|
||||||
|
errors.push(`Disease "${d.id}" has fewer than 3 treatment steps (${d.treatment.length})`);
|
||||||
|
}
|
||||||
|
if (d.prevention.length < 2) {
|
||||||
|
errors.push(`Disease "${d.id}" has fewer than 2 prevention tips (${d.prevention.length})`);
|
||||||
|
}
|
||||||
|
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||||
|
if (!diseaseIds.has(lookalikeId)) {
|
||||||
|
errors.push(`Disease "${d.id}" references unknown lookalike: ${lookalikeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bidirectionality check
|
||||||
|
for (const d of diseases) {
|
||||||
|
for (const lookalikeId of d.lookalikeDiseaseIds) {
|
||||||
|
const lookalike = diseases.find((ld) => ld.id === lookalikeId);
|
||||||
|
if (lookalike && !lookalike.lookalikeDiseaseIds.includes(d.id)) {
|
||||||
|
errors.push(
|
||||||
|
`Lookalike reference not bidirectional: "${d.id}" references "${lookalikeId}" but not vice versa`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("Knowledge Base Data", () => {
|
describe("Knowledge Base Data", () => {
|
||||||
it("has ≥20 plants", () => {
|
it("has ≥20 plants", () => {
|
||||||
@@ -31,305 +112,6 @@ describe("Knowledge Base Data", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPlantById", () => {
|
|
||||||
it("returns plant for known ID", () => {
|
|
||||||
const plant = getPlantById("tomato");
|
|
||||||
expect(plant).toBeDefined();
|
|
||||||
expect(plant!.commonName).toBe("Tomato");
|
|
||||||
expect(plant!.scientificName).toBe("Solanum lycopersicum");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for unknown ID", () => {
|
|
||||||
expect(getPlantById("nonexistent")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive", () => {
|
|
||||||
const plant = getPlantById("TOMATO");
|
|
||||||
expect(plant).toBeDefined();
|
|
||||||
expect(plant!.commonName).toBe("Tomato");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDiseaseById", () => {
|
|
||||||
it("returns disease for known ID", () => {
|
|
||||||
const disease = getDiseaseById("early-blight");
|
|
||||||
expect(disease).toBeDefined();
|
|
||||||
expect(disease!.name).toBe("Early Blight");
|
|
||||||
expect(disease!.plantId).toBe("tomato");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for unknown ID", () => {
|
|
||||||
expect(getDiseaseById("nonexistent")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDiseasesByPlantId", () => {
|
|
||||||
it("returns diseases for tomato", () => {
|
|
||||||
const diseases = getDiseasesByPlantId("tomato");
|
|
||||||
expect(diseases.length).toBeGreaterThanOrEqual(3);
|
|
||||||
expect(diseases.every((d) => d.plantId === "tomato")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for plant with no diseases", () => {
|
|
||||||
const diseases = getDiseasesByPlantId("nonexistent");
|
|
||||||
expect(diseases).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getPlantWithDiseases", () => {
|
|
||||||
it("returns plant with diseases for known ID", () => {
|
|
||||||
const result = getPlantWithDiseases("tomato");
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result!.plant.id).toBe("tomato");
|
|
||||||
expect(result!.diseases.length).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for unknown ID", () => {
|
|
||||||
expect(getPlantWithDiseases("nonexistent")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDiseaseWithPlant", () => {
|
|
||||||
it("returns disease with plant for known ID", () => {
|
|
||||||
const result = getDiseaseWithPlant("early-blight");
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result!.disease.id).toBe("early-blight");
|
|
||||||
expect(result!.plant.id).toBe("tomato");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for unknown ID", () => {
|
|
||||||
expect(getDiseaseWithPlant("nonexistent")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getLookalikeDiseases", () => {
|
|
||||||
it("returns lookalike diseases for early blight", () => {
|
|
||||||
const lookalikes = getLookalikeDiseases("early-blight");
|
|
||||||
expect(lookalikes.length).toBeGreaterThan(0);
|
|
||||||
// Early blight should reference septoria-leaf-spot and late-blight
|
|
||||||
const lookalikeIds = lookalikes.map((d) => d.id);
|
|
||||||
expect(lookalikeIds).toContain("septoria-leaf-spot");
|
|
||||||
expect(lookalikeIds).toContain("late-blight");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for disease with no lookalikes", () => {
|
|
||||||
const lookalikes = getLookalikeDiseases("tomato-powdery-mildew");
|
|
||||||
expect(lookalikes).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("searchPlants", () => {
|
|
||||||
it("returns all plants for empty search", () => {
|
|
||||||
const results = searchPlants("");
|
|
||||||
expect(results).toEqual(plants);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds tomato by common name", () => {
|
|
||||||
const results = searchPlants("tomato");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.some((p) => p.id === "tomato")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds plants by scientific name", () => {
|
|
||||||
const results = searchPlants("Solanum");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((p) => p.scientificName.includes("Solanum"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds plants by family", () => {
|
|
||||||
const results = searchPlants("Lamiaceae");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((p) => p.family === "Lamiaceae")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds plants by category", () => {
|
|
||||||
const results = searchPlants("houseplant");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for no matches", () => {
|
|
||||||
const results = searchPlants("xyznonexistent123");
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("searchDiseases", () => {
|
|
||||||
it("returns all diseases for empty search", () => {
|
|
||||||
const results = searchDiseases("");
|
|
||||||
expect(results).toEqual(diseases);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds diseases by name", () => {
|
|
||||||
const results = searchDiseases("blight");
|
|
||||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds diseases by scientific name", () => {
|
|
||||||
const results = searchDiseases("Alternaria");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds diseases by description content", () => {
|
|
||||||
const results = searchDiseases("calcium");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("finds diseases by symptom text", () => {
|
|
||||||
const results = searchDiseases("powdery");
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for no matches", () => {
|
|
||||||
const results = searchDiseases("xyznonexistent123");
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("listPlants", () => {
|
|
||||||
it("returns all plants with no filters", () => {
|
|
||||||
const results = listPlants();
|
|
||||||
expect(results).toEqual(plants);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by category", () => {
|
|
||||||
const results = listPlants({ category: "vegetable" });
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((p) => p.category === "vegetable")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("combines search and category filter", () => {
|
|
||||||
const results = listPlants({ search: "leaf", category: "houseplant" });
|
|
||||||
expect(results.every((p) => p.category === "houseplant")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("listDiseases", () => {
|
|
||||||
it("returns all diseases with no filters", () => {
|
|
||||||
const results = listDiseases();
|
|
||||||
expect(results).toEqual(diseases);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by plantId", () => {
|
|
||||||
const results = listDiseases({ plantId: "tomato" });
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by causalAgentType", () => {
|
|
||||||
const results = listDiseases({ causalAgentType: "fungal" });
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((d) => d.causalAgentType === "fungal")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by severity", () => {
|
|
||||||
const results = listDiseases({ severity: "critical" });
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((d) => d.severity === "critical")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("combines plantId and search filters", () => {
|
|
||||||
const results = listDiseases({ plantId: "tomato", search: "blight" });
|
|
||||||
expect(results.every((d) => d.plantId === "tomato")).toBe(true);
|
|
||||||
expect(results.every((d) => d.name.toLowerCase().includes("blight") || d.description.toLowerCase().includes("blight") || d.symptoms.some((s) => s.toLowerCase().includes("blight")))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateKnowledgeBase", () => {
|
|
||||||
it("returns no errors for valid data", () => {
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects invalid plant references", () => {
|
|
||||||
// Temporarily modify a disease to have invalid plantId
|
|
||||||
const original = diseases[0].plantId;
|
|
||||||
diseases[0].plantId = "nonexistent-plant";
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].plantId = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("nonexistent-plant"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects invalid causalAgentType", () => {
|
|
||||||
const original = diseases[0].causalAgentType;
|
|
||||||
(diseases[0] as any).causalAgentType = "invalid-type";
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].causalAgentType = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("invalid-type"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects invalid severity", () => {
|
|
||||||
const original = diseases[0].severity;
|
|
||||||
(diseases[0] as any).severity = "invalid-severity";
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].severity = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("invalid-severity"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects too few symptoms", () => {
|
|
||||||
const original = [...diseases[0].symptoms];
|
|
||||||
diseases[0].symptoms = ["only one"];
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].symptoms = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("fewer than 3 symptoms"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects too few causes", () => {
|
|
||||||
const original = [...diseases[0].causes];
|
|
||||||
diseases[0].causes = ["only one"];
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].causes = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("fewer than 2 causes"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects too few treatments", () => {
|
|
||||||
const original = [...diseases[0].treatment];
|
|
||||||
diseases[0].treatment = ["one", "two"];
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].treatment = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("fewer than 3 treatment"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects too few prevention tips", () => {
|
|
||||||
const original = [...diseases[0].prevention];
|
|
||||||
diseases[0].prevention = ["only one"];
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].prevention = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("fewer than 2 prevention"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects invalid lookalike references", () => {
|
|
||||||
const original = [...diseases[0].lookalikeDiseaseIds];
|
|
||||||
diseases[0].lookalikeDiseaseIds = ["nonexistent-disease"];
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
diseases[0].lookalikeDiseaseIds = original;
|
|
||||||
expect(errors.length).toBeGreaterThan(0);
|
|
||||||
expect(errors.some((e) => e.includes("nonexistent-disease"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects non-bidirectional lookalike references", () => {
|
|
||||||
// early-blight references septoria-leaf-spot and late-blight
|
|
||||||
// If we remove early-blight from septoria's lookalikes, it should flag
|
|
||||||
const septoria = diseases.find((d) => d.id === "septoria-leaf-spot");
|
|
||||||
if (septoria) {
|
|
||||||
const original = [...septoria.lookalikeDiseaseIds];
|
|
||||||
septoria.lookalikeDiseaseIds = septoria.lookalikeDiseaseIds.filter(
|
|
||||||
(id) => id !== "early-blight"
|
|
||||||
);
|
|
||||||
const errors = validateKnowledgeBase();
|
|
||||||
septoria.lookalikeDiseaseIds = original;
|
|
||||||
expect(errors.some((e) => e.includes("not bidirectional"))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Data quality checks", () => {
|
describe("Data quality checks", () => {
|
||||||
it("every disease has ≥3 symptoms", () => {
|
it("every disease has ≥3 symptoms", () => {
|
||||||
for (const d of diseases) {
|
for (const d of diseases) {
|
||||||
@@ -397,4 +179,23 @@ describe("Data quality checks", () => {
|
|||||||
const plantIds = new Set(diseases.map((d) => d.plantId));
|
const plantIds = new Set(diseases.map((d) => d.plantId));
|
||||||
expect(plantIds.size).toBeGreaterThanOrEqual(20);
|
expect(plantIds.size).toBeGreaterThanOrEqual(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("every disease has valid prevalence enum value", () => {
|
||||||
|
const validPrevalences: Prevalence[] = ["common", "uncommon", "rare", "very_rare"];
|
||||||
|
for (const d of diseases) {
|
||||||
|
if (d.prevalence !== undefined) {
|
||||||
|
expect(validPrevalences).toContain(d.prevalence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every plant has required fields", () => {
|
||||||
|
for (const p of plants) {
|
||||||
|
expect(p.id).toBeTruthy();
|
||||||
|
expect(p.commonName).toBeTruthy();
|
||||||
|
expect(p.scientificName).toBeTruthy();
|
||||||
|
expect(p.family).toBeTruthy();
|
||||||
|
expect(p.category).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const metadata: Metadata = {
|
|||||||
const faqs = [
|
const faqs = [
|
||||||
{
|
{
|
||||||
q: "How accurate is the disease identification?",
|
q: "How accurate is the disease identification?",
|
||||||
a: "Our model has been trained on 50K+ labeled plant disease images covering 25+ plant species. Accuracy varies by plant and disease type, with confidence scores provided for each diagnosis. The model performs best on common diseases with visible foliar symptoms. We recommend using multiple sources of information for critical plant health decisions.",
|
a: "Our model has been trained on 500K+ labeled plant disease images covering 300+ plant species. Accuracy varies by plant and disease type, with confidence scores provided for each diagnosis. The model performs best on common diseases with visible foliar symptoms. We recommend using multiple sources of information for critical plant health decisions.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "Which plants are supported?",
|
q: "Which plants are supported?",
|
||||||
@@ -46,7 +46,10 @@ function FAQAccordion() {
|
|||||||
>
|
>
|
||||||
<summary className="flex items-center justify-between gap-4 px-5 py-4 cursor-pointer list-none text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
<summary className="flex items-center justify-between gap-4 px-5 py-4 cursor-pointer list-none text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors">
|
||||||
{faq.q}
|
{faq.q}
|
||||||
<span className="shrink-0 text-zinc-400 group-open:rotate-180 transition-transform" aria-hidden="true">
|
<span
|
||||||
|
className="shrink-0 text-zinc-400 group-open:rotate-180 transition-transform"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="20"
|
||||||
@@ -63,9 +66,7 @@ function FAQAccordion() {
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="px-5 pb-4 pt-0">
|
<div className="px-5 pb-4 pt-0">
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{faq.a}</p>
|
||||||
{faq.a}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
@@ -92,21 +93,17 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
{/* Mission */}
|
{/* Mission */}
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Our Mission</h2>
|
||||||
Our Mission
|
|
||||||
</h2>
|
|
||||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Gardening is a labor of love — and watching a plant struggle with an
|
Gardening is a labor of love — and watching a plant struggle with an unknown disease is
|
||||||
unknown disease is heartbreaking. Our mission is to put the power of
|
heartbreaking. Our mission is to put the power of AI-powered disease identification into
|
||||||
AI-powered disease identification into every gardener's pocket,
|
every gardener's pocket, for free.
|
||||||
for free.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{APP_NAME} was built by a team of gardeners and developers who were
|
{APP_NAME} was built by a team of gardeners and developers who were frustrated with
|
||||||
frustrated with vague, generic plant disease advice. We wanted
|
vague, generic plant disease advice. We wanted hyper-specific diagnoses — not just
|
||||||
hyper-specific diagnoses — not just “your plant has a
|
“your plant has a fungus” but “your tomato has Late Blight caused by
|
||||||
fungus” but “your tomato has Late Blight caused by
|
|
||||||
Phytophthora infestans, and here's exactly how to treat it.”
|
Phytophthora infestans, and here's exactly how to treat it.”
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,29 +116,26 @@ export default function AboutPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||||
<p>
|
<p>
|
||||||
The identification engine uses a deep convolutional neural network
|
The identification engine uses a deep convolutional neural network trained on a dataset
|
||||||
trained on a dataset of <strong>50,000+ labeled plant disease
|
of <strong>500,000+ labeled plant disease images</strong> spanning 300+ plant species.
|
||||||
images</strong> spanning 25+ plant species. When you upload a photo:
|
When you upload a photo:
|
||||||
</p>
|
</p>
|
||||||
<ol className="list-decimal list-inside space-y-2">
|
<ol className="list-decimal list-inside space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<strong>Preprocessing</strong> — The image is normalized and
|
<strong>Preprocessing</strong> — The image is normalized and analyzed for relevant
|
||||||
analyzed for relevant regions (leaves, stems, fruit).
|
regions (leaves, stems, fruit).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Feature extraction</strong> — The model identifies visual
|
<strong>Feature extraction</strong> — The model identifies visual patterns: lesion
|
||||||
patterns: lesion shape, color, margin type, texture, and
|
shape, color, margin type, texture, and distribution.
|
||||||
distribution.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Classification</strong> — Patterns are matched against
|
<strong>Classification</strong> — Patterns are matched against known disease
|
||||||
known disease signatures, producing a ranked list of possible
|
signatures, producing a ranked list of possible diagnoses with confidence scores.
|
||||||
diagnoses with confidence scores.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Recommendation</strong> — The top diagnosis is paired with
|
<strong>Recommendation</strong> — The top diagnosis is paired with treatment steps,
|
||||||
treatment steps, prevention tips, and severity information from
|
prevention tips, and severity information from our curated knowledge base.
|
||||||
our curated knowledge base.
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,8 +148,8 @@ export default function AboutPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Our disease knowledge base is curated from peer-reviewed plant
|
Our disease knowledge base is curated from peer-reviewed plant pathology resources,
|
||||||
pathology resources, including:
|
including:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<ul className="list-disc list-inside space-y-1">
|
||||||
<li>University agricultural extension publications</li>
|
<li>University agricultural extension publications</li>
|
||||||
@@ -164,9 +158,8 @@ export default function AboutPage() {
|
|||||||
<li>Contributions from the open-source gardening community</li>
|
<li>Contributions from the open-source gardening community</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
We prioritize evidence-based, actionable information. Disease
|
We prioritize evidence-based, actionable information. Disease descriptions, treatments,
|
||||||
descriptions, treatments, and prevention tips are reviewed for
|
and prevention tips are reviewed for accuracy before inclusion.
|
||||||
accuracy before inclusion.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -181,15 +174,13 @@ export default function AboutPage() {
|
|||||||
<div className="text-sm text-warning-amber-700 dark:text-warning-amber-400 space-y-3">
|
<div className="text-sm text-warning-amber-700 dark:text-warning-amber-400 space-y-3">
|
||||||
<p>{BETA_DISCLAIMER}</p>
|
<p>{BETA_DISCLAIMER}</p>
|
||||||
<p>
|
<p>
|
||||||
The AI model may not accurately identify all diseases, especially
|
The AI model may not accurately identify all diseases, especially unusual
|
||||||
unusual presentations, early-stage infections, or diseases outside
|
presentations, early-stage infections, or diseases outside its training data. Always
|
||||||
its training data. Always confirm diagnoses with professional
|
confirm diagnoses with professional resources for critical decisions.
|
||||||
resources for critical decisions.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This tool is <strong>not</strong> FDA-approved or certified as a
|
This tool is <strong>not</strong> FDA-approved or certified as a medical/agricultural
|
||||||
medical/agricultural diagnostic device. It is an educational
|
diagnostic device. It is an educational assistive tool.
|
||||||
assistive tool.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,9 +193,9 @@ export default function AboutPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||||
<p>
|
<p>
|
||||||
{APP_NAME} is free and open source. We believe plant health
|
{APP_NAME} is free and open source. We believe plant health information should be
|
||||||
information should be accessible to everyone. The entire project is
|
accessible to everyone. The entire project is available on GitHub, and we welcome
|
||||||
available on GitHub, and we welcome contributions!
|
contributions!
|
||||||
</p>
|
</p>
|
||||||
<p>You can contribute by:</p>
|
<p>You can contribute by:</p>
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { getDiseaseById } from "@/lib/api/diseases";
|
import { getDiseaseById } from "@/lib/api/diseases-db";
|
||||||
|
|
||||||
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ describe("POST /api/identify", () => {
|
|||||||
const { data } = await callIdentify(imageId);
|
const { data } = await callIdentify(imageId);
|
||||||
|
|
||||||
for (const pred of data.predictions) {
|
for (const pred of data.predictions) {
|
||||||
const disease = getDiseaseById(pred.diseaseId);
|
const disease = await getDiseaseById(pred.diseaseId);
|
||||||
expect(disease).toBeDefined();
|
expect(disease).toBeDefined();
|
||||||
expect(disease!.id).toBe(pred.diseaseId);
|
expect(disease!.id).toBe(pred.diseaseId);
|
||||||
expect(disease!.name).toBe(pred.disease.name);
|
expect(disease!.name).toBe(pred.disease.name);
|
||||||
@@ -184,7 +184,7 @@ describe("POST /api/identify", () => {
|
|||||||
|
|
||||||
for (let i = 0; i < data.predictions.length - 1; i++) {
|
for (let i = 0; i < data.predictions.length - 1; i++) {
|
||||||
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
|
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
|
||||||
data.predictions[i + 1].confidence.adjusted
|
data.predictions[i + 1].confidence.adjusted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -194,7 +194,7 @@ describe("POST /api/identify", () => {
|
|||||||
|
|
||||||
for (const pred of data.predictions) {
|
for (const pred of data.predictions) {
|
||||||
for (const lookalikeId of pred.lookalikes) {
|
for (const lookalikeId of pred.lookalikes) {
|
||||||
const lookalike = getDiseaseById(lookalikeId);
|
const lookalike = await getDiseaseById(lookalikeId);
|
||||||
expect(lookalike).toBeDefined();
|
expect(lookalike).toBeDefined();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { runInference } from "@/lib/ml/inference";
|
|||||||
import { calibrateConfidence } from "@/lib/ml/confidence";
|
import { calibrateConfidence } from "@/lib/ml/confidence";
|
||||||
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
|
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
|
||||||
import { getModel } from "@/lib/ml/model-loader";
|
import { getModel } from "@/lib/ml/model-loader";
|
||||||
import { getDiseaseById, getPlantById } from "@/lib/api/diseases-db";
|
import { getDiseaseById, getPlantById, getLookalikeDiseases } from "@/lib/api/diseases-db";
|
||||||
import type { IdentifyRequest, IdentifyResponse, PredictionResult } from "@/lib/types";
|
import type { IdentifyRequest, IdentifyResponse, PredictionResult } from "@/lib/types";
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
@@ -121,7 +121,7 @@ async function preprocessImageBuffer(buffer: Buffer): Promise<Float32Array> {
|
|||||||
* For each prediction:
|
* For each prediction:
|
||||||
* - Look up disease by ID in knowledge base
|
* - Look up disease by ID in knowledge base
|
||||||
* - Calibrate confidence score
|
* - Calibrate confidence score
|
||||||
* - Include lookalike disease cross-references
|
* - Include lookalike disease cross-references (IDs and full objects)
|
||||||
*
|
*
|
||||||
* @param topPredictions - Top-K raw predictions from inference
|
* @param topPredictions - Top-K raw predictions from inference
|
||||||
* @returns Enriched prediction results
|
* @returns Enriched prediction results
|
||||||
@@ -149,8 +149,10 @@ async function enrichPredictions(
|
|||||||
// Calibrate confidence
|
// Calibrate confidence
|
||||||
const confidence = calibrateConfidence(pred.probability);
|
const confidence = calibrateConfidence(pred.probability);
|
||||||
|
|
||||||
// Get lookalike diseases
|
// Pre-resolve lookalike disease objects server-side so the client
|
||||||
|
// doesn't need sync access to JSON files
|
||||||
const lookalikes = disease.lookalikeDiseaseIds;
|
const lookalikes = disease.lookalikeDiseaseIds;
|
||||||
|
const lookalikeDiseases = await getLookalikeDiseases(diseaseId);
|
||||||
|
|
||||||
// Look up the plant for client convenience
|
// Look up the plant for client convenience
|
||||||
const plant = await getPlantById(disease.plantId).catch(() => null);
|
const plant = await getPlantById(disease.plantId).catch(() => null);
|
||||||
@@ -160,6 +162,7 @@ async function enrichPredictions(
|
|||||||
disease,
|
disease,
|
||||||
confidence,
|
confidence,
|
||||||
lookalikes,
|
lookalikes,
|
||||||
|
lookalikeDiseases,
|
||||||
plant: plant ?? null,
|
plant: plant ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import SymptomChecker from "@/components/SymptomChecker";
|
|||||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||||
import FlagButton from "@/components/FlagButton";
|
import FlagButton from "@/components/FlagButton";
|
||||||
import { getLookalikeDiseases } from "@/lib/api/diseases";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Individual disease result card with expandable sections.
|
* Individual disease result card with expandable sections.
|
||||||
@@ -31,9 +30,9 @@ export default function DiseaseCard({
|
|||||||
const [expanded, setExpanded] = useState(isPrimary);
|
const [expanded, setExpanded] = useState(isPrimary);
|
||||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||||
|
|
||||||
const { disease, confidence } = prediction;
|
const { disease, confidence, lookalikeDiseases } = prediction;
|
||||||
const colors = getConfidenceColors(confidence.label);
|
const colors = getConfidenceColors(confidence.label);
|
||||||
const lookalikes = getLookalikeDiseases(disease.id);
|
const lookalikes = lookalikeDiseases ?? [];
|
||||||
|
|
||||||
const toggleExpand = useCallback(() => {
|
const toggleExpand = useCallback(() => {
|
||||||
setExpanded((e) => !e);
|
setExpanded((e) => !e);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"family": "Solanaceae",
|
"family": "Solanaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained soil pH 6.0-6.8, regular feeding with balanced fertilizer, support with stakes or cages.",
|
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained soil pH 6.0-6.8, regular feeding with balanced fertilizer, support with stakes or cages.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Solanum_lycopersicum_-_Tomato.jpg/320px-Solanum_lycopersicum_-_Tomato.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/88/Bright_red_tomato_and_cross_section02.jpg/330px-Bright_red_tomato_and_cross_section02.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "basil",
|
"id": "basil",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
"scientificName": "Ocimum basilicum",
|
"scientificName": "Ocimum basilicum",
|
||||||
"family": "Lamiaceae",
|
"family": "Lamiaceae",
|
||||||
"category": "herb",
|
"category": "herb",
|
||||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil moist but not soggy), warm temperatures (70-90°F), pinching flowers encourages bushier growth.",
|
"careSummary": "Full sun (6-8h), moderate watering (keep soil moist but not soggy), warm temperatures (70-90\u00b0F), pinching flowers encourages bushier growth.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Basil.jpg/320px-Basil.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg/330px-Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "rose",
|
"id": "rose",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"family": "Rosaceae",
|
"family": "Rosaceae",
|
||||||
"category": "flower",
|
"category": "flower",
|
||||||
"careSummary": "Full sun (6h+), deep watering 2-3 times weekly, well-drained slightly acidic soil, regular deadheading, annual pruning in late winter.",
|
"careSummary": "Full sun (6h+), deep watering 2-3 times weekly, well-drained slightly acidic soil, regular deadheading, annual pruning in late winter.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Rosa_rubiginosa_002.JPG/320px-Rosa_rubiginosa_002.JPG"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Rosa_rubiginosa_002.JPG/330px-Rosa_rubiginosa_002.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "monstera",
|
"id": "monstera",
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
"scientificName": "Monstera deliciosa",
|
"scientificName": "Monstera deliciosa",
|
||||||
"family": "Araceae",
|
"family": "Araceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Bright indirect light, water when top 2-3 inches of soil are dry, humidity 60-80%, temperatures 65-85°F, well-draining aroid mix.",
|
"careSummary": "Bright indirect light, water when top 2-3 inches of soil are dry, humidity 60-80%, temperatures 65-85\u00b0F, well-draining aroid mix.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dc/Monstera_deliciosa_leaf.jpg/320px-Monstera_deliciosa_leaf.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Monstera_deliciosa_-_Wilhelma_01.jpg/330px-Monstera_deliciosa_-_Wilhelma_01.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "pothos",
|
"id": "pothos",
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
"scientificName": "Epipremnum aureum",
|
"scientificName": "Epipremnum aureum",
|
||||||
"family": "Araceae",
|
"family": "Araceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Low to bright indirect light, water when top inch of soil is dry, tolerates low humidity, temperatures 60-85°F, very forgiving and low-maintenance.",
|
"careSummary": "Low to bright indirect light, water when top inch of soil is dry, tolerates low humidity, temperatures 60-85\u00b0F, very forgiving and low-maintenance.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Epipremnum_aureum_2.jpg/320px-Epipremnum_aureum_2.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Epipremnum_aureum_2.jpg/330px-Epipremnum_aureum_2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "snake-plant",
|
"id": "snake-plant",
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"scientificName": "Dracaena trifasciata",
|
"scientificName": "Dracaena trifasciata",
|
||||||
"family": "Asparagaceae",
|
"family": "Asparagaceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Tolerates low to bright indirect light, water sparingly every 2-3 weeks, drought tolerant, temperatures 55-85°F, well-draining cactus mix.",
|
"careSummary": "Tolerates low to bright indirect light, water sparingly every 2-3 weeks, drought tolerant, temperatures 55-85\u00b0F, well-draining cactus mix.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Sansevieria_trifasciata_Laurentii.jpg/320px-Sansevieria_trifasciata_Laurentii.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/eb/20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg/330px-20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "peace-lily",
|
"id": "peace-lily",
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"scientificName": "Spathiphyllum wallisii",
|
"scientificName": "Spathiphyllum wallisii",
|
||||||
"family": "Araceae",
|
"family": "Araceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Low to medium indirect light, keep soil consistently moist but not waterlogged, high humidity preferred, temperatures 65-80°F, sensitive to fluoride in water.",
|
"careSummary": "Low to medium indirect light, keep soil consistently moist but not waterlogged, high humidity preferred, temperatures 65-80\u00b0F, sensitive to fluoride in water.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Spathiphyllum_wallisii_1.jpg/320px-Spathiphyllum_wallisii_1.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Peace_lily_-_1_-_cropped.jpg/330px-Peace_lily_-_1_-_cropped.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "orchid",
|
"id": "orchid",
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"scientificName": "Phalaenopsis amabilis",
|
"scientificName": "Phalaenopsis amabilis",
|
||||||
"family": "Orchidaceae",
|
"family": "Orchidaceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Bright indirect light, water weekly by soaking roots for 15 minutes then draining completely, humidity 50-70%, temperatures 65-80°F, bark-based orchid mix.",
|
"careSummary": "Bright indirect light, water weekly by soaking roots for 15 minutes then draining completely, humidity 50-70%, temperatures 65-80\u00b0F, bark-based orchid mix.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Phalaenopsis_amabilis_01.JPG/320px-Phalaenopsis_amabilis_01.JPG"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Ban_Ki-Moon_Orchid_Flower_Singapore_Feb23_D72_25479.jpg/330px-Ban_Ki-Moon_Orchid_Flower_Singapore_Feb23_D72_25479.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "succulent",
|
"id": "succulent",
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"scientificName": "Echeveria elegans",
|
"scientificName": "Echeveria elegans",
|
||||||
"family": "Crassulaceae",
|
"family": "Crassulaceae",
|
||||||
"category": "succulent",
|
"category": "succulent",
|
||||||
"careSummary": "Bright direct light (6h+), water only when soil is completely dry (soak and dry method), excellent drainage essential, temperatures 60-80°F, sandy well-draining mix.",
|
"careSummary": "Bright direct light (6h+), water only when soil is completely dry (soak and dry method), excellent drainage essential, temperatures 60-80\u00b0F, sandy well-draining mix.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Echeveria_Elegans_01.jpg/320px-Echeveria_Elegans_01.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Echeveria_A7CR_02866-90_zsp.jpg/330px-Echeveria_A7CR_02866-90_zsp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "pepper",
|
"id": "pepper",
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"scientificName": "Capsicum annuum",
|
"scientificName": "Capsicum annuum",
|
||||||
"family": "Solanaceae",
|
"family": "Solanaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), consistent watering, warm soil (70-80°F), well-drained fertile soil pH 6.0-6.8, regular feeding with high-potassium fertilizer during fruiting.",
|
"careSummary": "Full sun (6-8h), consistent watering, warm soil (70-80\u00b0F), well-drained fertile soil pH 6.0-6.8, regular feeding with high-potassium fertilizer during fruiting.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Capsicum_annuum_%27California_Wonder%27.jpg/320px-Capsicum_annuum_%27California_Wonder%27.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fd/2_x_Flat_red_bell_pepper_2017_A.jpg/330px-2_x_Flat_red_bell_pepper_2017_A.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cucumber",
|
"id": "cucumber",
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
"scientificName": "Cucumis sativus",
|
"scientificName": "Cucumis sativus",
|
||||||
"family": "Cucurbitaceae",
|
"family": "Cucurbitaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), consistent deep watering (1-2 inches/week), warm temperatures (70-95°F), trellis support recommended, mulch to retain moisture.",
|
"careSummary": "Full sun (6-8h), consistent deep watering (1-2 inches/week), warm temperatures (70-95\u00b0F), trellis support recommended, mulch to retain moisture.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Cucumis_sativus_002.jpg/320px-Cucumis_sativus_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/72/Cucumber_on_tomato_-_20180903_130208.jpg/330px-Cucumber_on_tomato_-_20180903_130208.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "squash",
|
"id": "squash",
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
"scientificName": "Cucurbita pepo",
|
"scientificName": "Cucurbita pepo",
|
||||||
"family": "Cucurbitaceae",
|
"family": "Cucurbitaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, space plants 2-3 feet apart.",
|
"careSummary": "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80\u00b0F), well-drained fertile soil, space plants 2-3 feet apart.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0a/CSA-Yellow-Squash.jpg/330px-CSA-Yellow-Squash.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bean",
|
"id": "bean",
|
||||||
@@ -113,17 +113,17 @@
|
|||||||
"scientificName": "Phaseolus vulgaris",
|
"scientificName": "Phaseolus vulgaris",
|
||||||
"family": "Fabaceae",
|
"family": "Fabaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), moderate watering (keep soil evenly moist), warm temperatures (65-80°F), trellis for pole varieties, benefits from nitrogen-fixing roots.",
|
"careSummary": "Full sun (6-8h), moderate watering (keep soil evenly moist), warm temperatures (65-80\u00b0F), trellis for pole varieties, benefits from nitrogen-fixing roots.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Phaseolus_vulgaris_003.jpg/320px-Phaseolus_vulgaris_003.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1c/-2020-08-02_Bobby_bean_harvest_%28Phaseolus_vulgaris%29%2C_Trimingham%2C_Norfolk.JPG/330px--2020-08-02_Bobby_bean_harvest_%28Phaseolus_vulgaris%29%2C_Trimingham%2C_Norfolk.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "strawberry",
|
"id": "strawberry",
|
||||||
"commonName": "Strawberry",
|
"commonName": "Strawberry",
|
||||||
"scientificName": "Fragaria × ananassa",
|
"scientificName": "Fragaria \u00d7 ananassa",
|
||||||
"family": "Rosaceae",
|
"family": "Rosaceae",
|
||||||
"category": "fruit",
|
"category": "fruit",
|
||||||
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained slightly acidic soil pH 5.5-6.5, mulch with straw to protect fruit, remove runners for larger berries.",
|
"careSummary": "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained slightly acidic soil pH 5.5-6.5, mulch with straw to protect fruit, remove runners for larger berries.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Fragaria_x_ananassa_002.jpg/320px-Fragaria_x_ananassa_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Erdbeere_Closeup_%28126599651%29.jpeg/330px-Erdbeere_Closeup_%28126599651%29.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mint",
|
"id": "mint",
|
||||||
@@ -131,8 +131,8 @@
|
|||||||
"scientificName": "Mentha spp.",
|
"scientificName": "Mentha spp.",
|
||||||
"family": "Lamiaceae",
|
"family": "Lamiaceae",
|
||||||
"category": "herb",
|
"category": "herb",
|
||||||
"careSummary": "Partial shade to full sun, keep soil consistently moist, cool to warm temperatures (60-70°F), container growing recommended to prevent spreading, regular harvesting encourages growth.",
|
"careSummary": "Partial shade to full sun, keep soil consistently moist, cool to warm temperatures (60-70\u00b0F), container growing recommended to prevent spreading, regular harvesting encourages growth.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Mentha_spicata_002.jpg/320px-Mentha_spicata_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/A_mint_leaves_in_Yuen_Long.jpg/330px-A_mint_leaves_in_Yuen_Long.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "lavender",
|
"id": "lavender",
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"scientificName": "Lavandula angustifolia",
|
"scientificName": "Lavandula angustifolia",
|
||||||
"family": "Lamiaceae",
|
"family": "Lamiaceae",
|
||||||
"category": "herb",
|
"category": "herb",
|
||||||
"careSummary": "Full sun (6-8h+), drought tolerant once established, well-drained alkaline soil pH 6.5-7.5, prune after flowering, temperatures 50-75°F.",
|
"careSummary": "Full sun (6-8h+), drought tolerant once established, well-drained alkaline soil pH 6.5-7.5, prune after flowering, temperatures 50-75\u00b0F.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Lavandula_angustifolia_002.jpg/320px-Lavandula_angustifolia_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Bee_on_Lavender_Blossom_2.jpg/330px-Bee_on_Lavender_Blossom_2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "lettuce",
|
"id": "lettuce",
|
||||||
@@ -149,8 +149,8 @@
|
|||||||
"scientificName": "Lactuca sativa",
|
"scientificName": "Lactuca sativa",
|
||||||
"family": "Asteraceae",
|
"family": "Asteraceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Partial shade to full sun, consistent moisture (shallow watering), cool temperatures (55-75°F), well-drained fertile soil, succession planting every 2 weeks.",
|
"careSummary": "Partial shade to full sun, consistent moisture (shallow watering), cool temperatures (55-75\u00b0F), well-drained fertile soil, succession planting every 2 weeks.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Lactuca_sativa_002.jpg/320px-Lactuca_sativa_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Butterhead_lettuce.jpg/330px-Butterhead_lettuce.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cabbage",
|
"id": "cabbage",
|
||||||
@@ -158,8 +158,8 @@
|
|||||||
"scientificName": "Brassica oleracea var. capitata",
|
"scientificName": "Brassica oleracea var. capitata",
|
||||||
"family": "Brassicaceae",
|
"family": "Brassicaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), consistent deep watering, cool to moderate temperatures (50-85°F), rich well-drained soil, side-dress with nitrogen mid-season.",
|
"careSummary": "Full sun (6-8h), consistent deep watering, cool to moderate temperatures (50-85\u00b0F), rich well-drained soil, side-dress with nitrogen mid-season.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Bok_choy.jpg/320px-Bok_choy.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/Brassica_oleracea_var._capitata_%284170722993%29.jpg/330px-Brassica_oleracea_var._capitata_%284170722993%29.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "sunflower",
|
"id": "sunflower",
|
||||||
@@ -167,8 +167,8 @@
|
|||||||
"scientificName": "Helianthus annuus",
|
"scientificName": "Helianthus annuus",
|
||||||
"family": "Asteraceae",
|
"family": "Asteraceae",
|
||||||
"category": "flower",
|
"category": "flower",
|
||||||
"careSummary": "Full sun (6-8h+), moderate watering (deep but infrequent), warm temperatures (70-78°F), well-drained soil, tall varieties need staking in wind.",
|
"careSummary": "Full sun (6-8h+), moderate watering (deep but infrequent), warm temperatures (70-78\u00b0F), well-drained soil, tall varieties need staking in wind.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Helianthus_annuus_in_Jena.jpg/320px-Helianthus_annuus_in_Jena.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Close_up_sunflower_in_bloom_Mongolia.jpg/330px-Close_up_sunflower_in_bloom_Mongolia.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fiddle-leaf-fig",
|
"id": "fiddle-leaf-fig",
|
||||||
@@ -176,8 +176,8 @@
|
|||||||
"scientificName": "Ficus lyrata",
|
"scientificName": "Ficus lyrata",
|
||||||
"family": "Moraceae",
|
"family": "Moraceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Bright indirect light (no direct harsh sun), water when top 1-2 inches of soil are dry, humidity 40-60%, temperatures 60-75°F, avoid moving once placed.",
|
"careSummary": "Bright indirect light (no direct harsh sun), water when top 1-2 inches of soil are dry, humidity 40-60%, temperatures 60-75\u00b0F, avoid moving once placed.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Ficus_lyrata_002.jpg/320px-Ficus_lyrata_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/Ficus_lyrata_8zz.jpg/330px-Ficus_lyrata_8zz.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "aloe-vera",
|
"id": "aloe-vera",
|
||||||
@@ -185,8 +185,8 @@
|
|||||||
"scientificName": "Aloe barbadensis miller",
|
"scientificName": "Aloe barbadensis miller",
|
||||||
"family": "Asphodelaceae",
|
"family": "Asphodelaceae",
|
||||||
"category": "succulent",
|
"category": "succulent",
|
||||||
"careSummary": "Bright indirect to direct light, water deeply every 2-3 weeks, allow soil to dry completely between waterings, temperatures 55-80°F, well-draining cactus mix.",
|
"careSummary": "Bright indirect to direct light, water deeply every 2-3 weeks, allow soil to dry completely between waterings, temperatures 55-80\u00b0F, well-draining cactus mix.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Aloe_vera_leaf_cutaway.jpg/320px-Aloe_vera_leaf_cutaway.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b5/Aloe_Vera.jpg/330px-Aloe_Vera.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jasmine",
|
"id": "jasmine",
|
||||||
@@ -194,8 +194,8 @@
|
|||||||
"scientificName": "Jasminum officinale",
|
"scientificName": "Jasminum officinale",
|
||||||
"family": "Oleaceae",
|
"family": "Oleaceae",
|
||||||
"category": "flower",
|
"category": "flower",
|
||||||
"careSummary": "Full sun to partial shade (6h+), regular watering (keep soil moist), warm temperatures (60-75°F), trellis support for climbing varieties, prune after flowering.",
|
"careSummary": "Full sun to partial shade (6h+), regular watering (keep soil moist), warm temperatures (60-75\u00b0F), trellis support for climbing varieties, prune after flowering.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Jasminum_officinale_002.jpg/320px-Jasminum_officinale_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cb/Flowers_White_Jasmine.jpg/330px-Flowers_White_Jasmine.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "chili",
|
"id": "chili",
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
"scientificName": "Capsicum chinense",
|
"scientificName": "Capsicum chinense",
|
||||||
"family": "Solanaceae",
|
"family": "Solanaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (8h+), consistent watering (not waterlogged), warm temperatures (70-85°F), well-drained fertile soil, high-potassium fertilizer during fruiting.",
|
"careSummary": "Full sun (8h+), consistent watering (not waterlogged), warm temperatures (70-85\u00b0F), well-drained fertile soil, high-potassium fertilizer during fruiting.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Capsicum_chinense_%27Habanero%27.jpg/320px-Capsicum_chinense_%27Habanero%27.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/77/A_Fat_Red_Cayenne_Pepper.jpg/330px-A_Fat_Red_Cayenne_Pepper.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "eggplant",
|
"id": "eggplant",
|
||||||
@@ -212,8 +212,8 @@
|
|||||||
"scientificName": "Solanum melongena",
|
"scientificName": "Solanum melongena",
|
||||||
"family": "Solanaceae",
|
"family": "Solanaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), consistent deep watering, warm temperatures (70-85°F), well-drained fertile soil, mulch to retain moisture, stake or cage for support.",
|
"careSummary": "Full sun (6-8h), consistent deep watering, warm temperatures (70-85\u00b0F), well-drained fertile soil, mulch to retain moisture, stake or cage for support.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Eggplant_01.jpg/320px-Eggplant_01.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1f/Eggplant_01.jpg/330px-Eggplant_01.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "spinach",
|
"id": "spinach",
|
||||||
@@ -221,8 +221,8 @@
|
|||||||
"scientificName": "Spinacia oleracea",
|
"scientificName": "Spinacia oleracea",
|
||||||
"family": "Amaranthaceae",
|
"family": "Amaranthaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Partial shade to full sun, consistent moisture, cool temperatures (50-70°F), well-drained fertile soil, bolt quickly in heat — plant in spring or fall.",
|
"careSummary": "Partial shade to full sun, consistent moisture, cool temperatures (50-70\u00b0F), well-drained fertile soil, bolt quickly in heat \u2014 plant in spring or fall.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Spinach_leaves.jpg/320px-Spinach_leaves.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/Spinach_leaves.jpg/330px-Spinach_leaves.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fern",
|
"id": "fern",
|
||||||
@@ -230,17 +230,17 @@
|
|||||||
"scientificName": "Nephrolepis exaltata",
|
"scientificName": "Nephrolepis exaltata",
|
||||||
"family": "Nephrolepidaceae",
|
"family": "Nephrolepidaceae",
|
||||||
"category": "houseplant",
|
"category": "houseplant",
|
||||||
"careSummary": "Bright indirect light, keep soil consistently moist (never dry out), high humidity 50-80%, temperatures 60-75°F, regular misting or humidity tray recommended.",
|
"careSummary": "Bright indirect light, keep soil consistently moist (never dry out), high humidity 50-80%, temperatures 60-75\u00b0F, regular misting or humidity tray recommended.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Nephrolepis_exaltata_002.jpg/320px-Nephrolepis_exaltata_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5f/Boston_Fern_%28Nephrolepis_exaltata%29.jpg/330px-Boston_Fern_%28Nephrolepis_exaltata%29.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "daisy",
|
"id": "daisy",
|
||||||
"commonName": "Shasta Daisy",
|
"commonName": "Shasta Daisy",
|
||||||
"scientificName": "Leucanthemum × superbum",
|
"scientificName": "Leucanthemum \u00d7 superbum",
|
||||||
"family": "Asteraceae",
|
"family": "Asteraceae",
|
||||||
"category": "flower",
|
"category": "flower",
|
||||||
"careSummary": "Full sun (6h+), moderate watering, cool to moderate temperatures (60-75°F), well-drained soil, deadhead spent blooms, divide clumps every 3-4 years.",
|
"careSummary": "Full sun (6h+), moderate watering, cool to moderate temperatures (60-75\u00b0F), well-drained soil, deadhead spent blooms, divide clumps every 3-4 years.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Leucanthemum_superbum_002.jpg/320px-Leucanthemum_superbum_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b8/Daisy_flower_clicked_by_somya.jpg/330px-Daisy_flower_clicked_by_somya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "zucchini",
|
"id": "zucchini",
|
||||||
@@ -248,8 +248,8 @@
|
|||||||
"scientificName": "Cucurbita pepo var. cylindrica",
|
"scientificName": "Cucurbita pepo var. cylindrica",
|
||||||
"family": "Cucurbitaceae",
|
"family": "Cucurbitaceae",
|
||||||
"category": "vegetable",
|
"category": "vegetable",
|
||||||
"careSummary": "Full sun (6-8h), deep consistent watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, harvest when 6-8 inches for best flavor.",
|
"careSummary": "Full sun (6-8h), deep consistent watering (1-2 inches/week), warm temperatures (65-80\u00b0F), well-drained fertile soil, harvest when 6-8 inches for best flavor.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/Courgette_Cucurbita_pepo_2.jpg/330px-Courgette_Cucurbita_pepo_2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cactus",
|
"id": "cactus",
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
"scientificName": "Opuntia ficus-indica",
|
"scientificName": "Opuntia ficus-indica",
|
||||||
"family": "Cactaceae",
|
"family": "Cactaceae",
|
||||||
"category": "succulent",
|
"category": "succulent",
|
||||||
"careSummary": "Full sun (8h+), water sparingly (every 2-4 weeks in growing season, almost none in winter), extremely well-draining soil, temperatures 55-100°F, excellent heat/drought tolerance.",
|
"careSummary": "Full sun (8h+), water sparingly (every 2-4 weeks in growing season, almost none in winter), extremely well-draining soil, temperatures 55-100\u00b0F, excellent heat/drought tolerance.",
|
||||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Opuntia_ficus-indica_001.jpg/320px-Opuntia_ficus-indica_001.jpg"
|
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Cactus_%28Opuntia_phaeacantha%29_flower.JPG/330px-Cactus_%28Opuntia_phaeacantha%29_flower.JPG"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed helpers to query the plant disease knowledge base.
|
|
||||||
* All functions operate on the JSON seed data files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CausalAgentType,
|
|
||||||
Disease,
|
|
||||||
DiseaseListParams,
|
|
||||||
DiseaseWithPlant,
|
|
||||||
Plant,
|
|
||||||
PlantListParams,
|
|
||||||
PlantWithDiseases,
|
|
||||||
Severity,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
import rawPlants from "@/data/plants.json";
|
|
||||||
import rawDiseases from "@/data/diseases.json";
|
|
||||||
|
|
||||||
// Cast JSON imports to typed arrays
|
|
||||||
const plants: Plant[] = rawPlants as Plant[];
|
|
||||||
const diseases: Disease[] = rawDiseases as Disease[];
|
|
||||||
|
|
||||||
// Re-export raw data for direct access if needed
|
|
||||||
export { plants, diseases };
|
|
||||||
|
|
||||||
// Lookup maps for O(1) access
|
|
||||||
const plantMap = new Map(plants.map((p) => [p.id, p]));
|
|
||||||
const diseaseMap = new Map(diseases.map((d) => [d.id, d]));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a plant by its ID.
|
|
||||||
* @returns The plant or undefined if not found.
|
|
||||||
*/
|
|
||||||
export function getPlantById(id: string): Plant | undefined {
|
|
||||||
return plantMap.get(id.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a disease by its ID.
|
|
||||||
* @returns The disease or undefined if not found.
|
|
||||||
*/
|
|
||||||
export function getDiseaseById(id: string): Disease | undefined {
|
|
||||||
return diseaseMap.get(id.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all diseases for a specific plant.
|
|
||||||
* @returns Array of diseases for the plant.
|
|
||||||
*/
|
|
||||||
export function getDiseasesByPlantId(plantId: string): Disease[] {
|
|
||||||
return diseases.filter(
|
|
||||||
(d) => d.plantId.toLowerCase() === plantId.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a plant with all its associated diseases.
|
|
||||||
* @returns PlantWithDiseases or undefined if plant not found.
|
|
||||||
*/
|
|
||||||
export function getPlantWithDiseases(
|
|
||||||
plantId: string
|
|
||||||
): PlantWithDiseases | undefined {
|
|
||||||
const plant = getPlantById(plantId);
|
|
||||||
if (!plant) return undefined;
|
|
||||||
return {
|
|
||||||
plant,
|
|
||||||
diseases: getDiseasesByPlantId(plantId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a disease with its associated plant.
|
|
||||||
* @returns DiseaseWithPlant or undefined if disease not found.
|
|
||||||
*/
|
|
||||||
export function getDiseaseWithPlant(
|
|
||||||
diseaseId: string
|
|
||||||
): DiseaseWithPlant | undefined {
|
|
||||||
const disease = getDiseaseById(diseaseId);
|
|
||||||
if (!disease) return undefined;
|
|
||||||
const plant = getPlantById(disease.plantId);
|
|
||||||
if (!plant) return undefined;
|
|
||||||
return { disease, plant };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve lookalike disease IDs to full disease objects.
|
|
||||||
* @returns Array of lookalike diseases.
|
|
||||||
*/
|
|
||||||
export function getLookalikeDiseases(diseaseId: string): Disease[] {
|
|
||||||
const disease = getDiseaseById(diseaseId);
|
|
||||||
if (!disease || !disease.lookalikeDiseaseIds.length) return [];
|
|
||||||
return disease.lookalikeDiseaseIds
|
|
||||||
.map((id) => getDiseaseById(id))
|
|
||||||
.filter((d): d is Disease => d !== undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search plants by term (matches common name, scientific name, family, category).
|
|
||||||
* @param term - Search term (case-insensitive).
|
|
||||||
* @returns Matching plants.
|
|
||||||
*/
|
|
||||||
export function searchPlants(term: string): Plant[] {
|
|
||||||
const lower = term.toLowerCase().trim();
|
|
||||||
if (!lower) return plants;
|
|
||||||
return plants.filter(
|
|
||||||
(p) =>
|
|
||||||
p.commonName.toLowerCase().includes(lower) ||
|
|
||||||
p.scientificName.toLowerCase().includes(lower) ||
|
|
||||||
p.family.toLowerCase().includes(lower) ||
|
|
||||||
p.category.toLowerCase().includes(lower)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search diseases by term (matches name, scientific name, description, symptoms).
|
|
||||||
* @param term - Search term (case-insensitive).
|
|
||||||
* @returns Matching diseases.
|
|
||||||
*/
|
|
||||||
export function searchDiseases(term: string): Disease[] {
|
|
||||||
const lower = term.toLowerCase().trim();
|
|
||||||
if (!lower) return diseases;
|
|
||||||
return diseases.filter(
|
|
||||||
(d) =>
|
|
||||||
d.name.toLowerCase().includes(lower) ||
|
|
||||||
d.scientificName.toLowerCase().includes(lower) ||
|
|
||||||
d.description.toLowerCase().includes(lower) ||
|
|
||||||
d.symptoms.some((s) => s.toLowerCase().includes(lower))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List plants with optional search and category filters.
|
|
||||||
*/
|
|
||||||
export function listPlants(params: PlantListParams = {}): Plant[] {
|
|
||||||
let result = plants;
|
|
||||||
if (params.category) {
|
|
||||||
result = result.filter(
|
|
||||||
(p) => p.category === params.category
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (params.search) {
|
|
||||||
const lower = params.search.toLowerCase().trim();
|
|
||||||
result = result.filter(
|
|
||||||
(p) =>
|
|
||||||
p.commonName.toLowerCase().includes(lower) ||
|
|
||||||
p.scientificName.toLowerCase().includes(lower) ||
|
|
||||||
p.family.toLowerCase().includes(lower) ||
|
|
||||||
p.category.toLowerCase().includes(lower)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List diseases with optional filters.
|
|
||||||
*/
|
|
||||||
export function listDiseases(params: DiseaseListParams = {}): Disease[] {
|
|
||||||
let result = diseases;
|
|
||||||
if (params.plantId) {
|
|
||||||
result = result.filter(
|
|
||||||
(d) => d.plantId.toLowerCase() === params.plantId!.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (params.causalAgentType) {
|
|
||||||
result = result.filter(
|
|
||||||
(d) => d.causalAgentType === params.causalAgentType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (params.severity) {
|
|
||||||
result = result.filter((d) => d.severity === params.severity);
|
|
||||||
}
|
|
||||||
if (params.search) {
|
|
||||||
const lower = params.search.toLowerCase().trim();
|
|
||||||
result = result.filter(
|
|
||||||
(d) =>
|
|
||||||
d.name.toLowerCase().includes(lower) ||
|
|
||||||
d.scientificName.toLowerCase().includes(lower) ||
|
|
||||||
d.description.toLowerCase().includes(lower) ||
|
|
||||||
d.symptoms.some((s) => s.toLowerCase().includes(lower))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique plant IDs that have diseases.
|
|
||||||
*/
|
|
||||||
export function getPlantIdsWithDiseases(): string[] {
|
|
||||||
return [...new Set(diseases.map((d) => d.plantId))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique disease IDs referenced as lookalikes.
|
|
||||||
*/
|
|
||||||
export function getReferencedLookalikeIds(): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const disease of diseases) {
|
|
||||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
|
||||||
ids.add(lookalikeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate knowledge base data integrity.
|
|
||||||
* @returns Array of validation errors (empty = valid).
|
|
||||||
*/
|
|
||||||
export function validateKnowledgeBase(): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
const validCausalAgentTypes: CausalAgentType[] = [
|
|
||||||
"fungal",
|
|
||||||
"bacterial",
|
|
||||||
"viral",
|
|
||||||
"environmental",
|
|
||||||
];
|
|
||||||
const validSeverities: Severity[] = ["low", "moderate", "high", "critical"];
|
|
||||||
|
|
||||||
// Check all plant IDs are unique
|
|
||||||
const plantIds = new Set<string>();
|
|
||||||
for (const plant of plants) {
|
|
||||||
if (plantIds.has(plant.id)) {
|
|
||||||
errors.push(`Duplicate plant ID: ${plant.id}`);
|
|
||||||
}
|
|
||||||
plantIds.add(plant.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all disease IDs are unique
|
|
||||||
const diseaseIds = new Set<string>();
|
|
||||||
for (const disease of diseases) {
|
|
||||||
if (diseaseIds.has(disease.id)) {
|
|
||||||
errors.push(`Duplicate disease ID: ${disease.id}`);
|
|
||||||
}
|
|
||||||
diseaseIds.add(disease.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each disease
|
|
||||||
for (const disease of diseases) {
|
|
||||||
// Valid plant reference
|
|
||||||
if (!plantIds.has(disease.plantId)) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" references unknown plant ID: ${disease.plantId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid causal agent type
|
|
||||||
if (!validCausalAgentTypes.includes(disease.causalAgentType)) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has invalid causalAgentType: ${disease.causalAgentType}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid severity
|
|
||||||
if (!validSeverities.includes(disease.severity)) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has invalid severity: ${disease.severity}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum symptom count
|
|
||||||
if (disease.symptoms.length < 3) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has fewer than 3 symptoms (${disease.symptoms.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum cause count
|
|
||||||
if (disease.causes.length < 2) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has fewer than 2 causes (${disease.causes.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum treatment count
|
|
||||||
if (disease.treatment.length < 3) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has fewer than 3 treatment steps (${disease.treatment.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimum prevention count
|
|
||||||
if (disease.prevention.length < 2) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" has fewer than 2 prevention tips (${disease.prevention.length})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid lookalike references
|
|
||||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
|
||||||
if (!diseaseIds.has(lookalikeId)) {
|
|
||||||
errors.push(
|
|
||||||
`Disease "${disease.id}" references unknown lookalike: ${lookalikeId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check lookalike bidirectionality (optional warning, not error)
|
|
||||||
for (const disease of diseases) {
|
|
||||||
for (const lookalikeId of disease.lookalikeDiseaseIds) {
|
|
||||||
const lookalike = getDiseaseById(lookalikeId);
|
|
||||||
if (
|
|
||||||
lookalike &&
|
|
||||||
!lookalike.lookalikeDiseaseIds.includes(disease.id)
|
|
||||||
) {
|
|
||||||
errors.push(
|
|
||||||
`Lookalike reference not bidirectional: "${disease.id}" references "${lookalikeId}" but not vice versa`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@ export const FEATURED_PLANT_IDS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const TRUST_SIGNALS = [
|
export const TRUST_SIGNALS = [
|
||||||
{ icon: "📸", label: "Trained on 50K+ images" },
|
{ icon: "📸", label: "Trained on 500K+ images" },
|
||||||
{ icon: "🌿", label: "Covers 300+ plants with 10K+ diseases" },
|
{ icon: "🌿", label: "Covers 300+ plants with 10K+ diseases" },
|
||||||
{ icon: "🔓", label: "Open source" },
|
{ icon: "🔓", label: "Open source" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -62,7 +62,7 @@ export const HOW_IT_WORKS = [
|
|||||||
emoji: "🧠",
|
emoji: "🧠",
|
||||||
title: "AI Analysis",
|
title: "AI Analysis",
|
||||||
description:
|
description:
|
||||||
"Our model analyzes the image against 50K+ labeled plant disease images in seconds.",
|
"Our model analyzes the image against 500K+ labeled plant disease images in seconds.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: 3,
|
step: 3,
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ export interface PredictionResult {
|
|||||||
confidence: ConfidenceResult;
|
confidence: ConfidenceResult;
|
||||||
/** IDs of lookalike diseases that could be confused with this one */
|
/** IDs of lookalike diseases that could be confused with this one */
|
||||||
lookalikes: string[];
|
lookalikes: string[];
|
||||||
|
/** Full disease objects for lookalikes, pre-resolved server-side */
|
||||||
|
lookalikeDiseases: Disease[];
|
||||||
/** The plant this disease affects (included for client convenience) */
|
/** The plant this disease affects (included for client convenience) */
|
||||||
plant: Plant | null;
|
plant: Plant | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,14 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "src/test/**"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"scripts",
|
||||||
|
"tasks",
|
||||||
|
"coverage",
|
||||||
|
"data",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"src/test/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Plant Disease Knowledge Base Generator
|
|
||||||
*
|
|
||||||
* Generates ~9,300 disease entries across 200+ plant species using
|
|
||||||
* authoritative disease patterns from UW-Madison PDDC and Cornell PDDC factsheets.
|
|
||||||
*
|
|
||||||
* Sources:
|
|
||||||
* - UW-Madison PDDC: https://pddc.wisc.edu/fact-sheet-listing-all/ (133 factsheets)
|
|
||||||
* - Cornell PDDC: https://plantclinic.cornell.edu/factsheets/ (~113 factsheets)
|
|
||||||
*
|
|
||||||
* Usage: node scripts/generate-diseases.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function slugify(str) {
|
|
||||||
return str
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.trim()
|
|
||||||
.replace(/^-|-$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(arr, n = 1) {
|
|
||||||
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
|
||||||
return n === 1 ? shuffled[0] : shuffled.slice(0, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickRange(min, max) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Plant Database (200+ species across all categories) ─────────────────────
|
|
||||||
|
|
||||||
const PLANTS = [
|
|
||||||
// ── Vegetables (Solanaceae) ────────────────────────────────────────────
|
|
||||||
{ id: "tomato", commonName: "Tomato", scientificName: "Solanum lycopersicum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering (1-2 inches/week), well-drained soil pH 6.0-6.8, regular feeding with balanced fertilizer, support with stakes or cages.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Solanum_lycopersicum_-_Tomato.jpg/320px-Solanum_lycopersicum_-_Tomato.jpg" },
|
|
||||||
{ id: "pepper", commonName: "Bell Pepper", scientificName: "Capsicum annuum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, warm soil (70-80°F), well-drained fertile soil pH 6.0-6.8, regular feeding with high-potassium fertilizer during fruiting.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Capsicum_annuum_%27California_Wonder%27.jpg/320px-Capsicum_annuum_%27California_Wonder%27.jpg" },
|
|
||||||
{ id: "chili", commonName: "Chili Pepper", scientificName: "Capsicum chinense", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (8h+), consistent watering (not waterlogged), warm temperatures (70-85°F), well-drained fertile soil, high-potassium fertilizer during fruiting.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Capsicum_chinense_%27Habanero%27.jpg/320px-Capsicum_chinense_%27Habanero%27.jpg" },
|
|
||||||
{ id: "eggplant", commonName: "Eggplant", scientificName: "Solanum melongena", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent deep watering, warm temperatures (70-85°F), well-drained fertile soil, mulch to retain moisture, stake or cage for support.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/94/Eggplant_01.jpg/320px-Eggplant_01.jpg" },
|
|
||||||
{ id: "potato", commonName: "Potato", scientificName: "Solanum tuberosum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering (1-2 inches/week), cool temperatures (59-70°F), loose well-drained soil pH 4.8-6.5, hill soil around stems as plants grow.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Solanum_tuberosum_002.jpg/320px-Solanum_tuberosum_002.jpg" },
|
|
||||||
{ id: "tobacco", commonName: "Tobacco", scientificName: "Nicotiana tabacum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (6-8h), moderate watering, warm temperatures (65-85°F), well-drained fertile soil, space plants 12-18 inches apart.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/Nicotiana_tabacum1.jpg/320px-Nicotiana_tabacum1.jpg" },
|
|
||||||
{ id: "pepper-jalapeno", commonName: "Jalapeño Pepper", scientificName: "Capsicum annuum var. annuum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (8h+), consistent watering, warm temperatures (70-85°F), well-drained fertile soil, stake for support, harvest when fully colored.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Capsicum_annuum_%27Jalape%C3%B1o%27.jpg/320px-Capsicum_annuum_%27Jalape%C3%B1o%27.jpg" },
|
|
||||||
{ id: "pepper-serrano", commonName: "Serrano Pepper", scientificName: "Capsicum annuum var. glabriusculum", family: "Solanaceae", category: "vegetable", careSummary: "Full sun (8h+), moderate watering, warm temperatures (70-85°F), well-drained soil, minimal fertilizer, harvest green or red.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Capsicum_annuum_%27Serrano%27.jpg/320px-Capsicum_annuum_%27Serrano%27.jpg" },
|
|
||||||
{ id: "nightshade", commonName: "Garden Nightshade", scientificName: "Solanum nigrum", family: "Solanaceae", category: "vegetable", careSummary: "Partial shade to full sun, moderate watering, adaptable to various soils, self-seeds readily.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Solanum_nigrum_002.jpg/320px-Solanum_nigrum_002.jpg" },
|
|
||||||
|
|
||||||
// ── Vegetables (Cucurbitaceae) ─────────────────────────────────────────
|
|
||||||
{ id: "cucumber", commonName: "Cucumber", scientificName: "Cucumis sativus", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent deep watering (1-2 inches/week), warm temperatures (70-95°F), trellis support recommended, mulch to retain moisture.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Cucumis_sativus_002.jpg/320px-Cucumis_sativus_002.jpg" },
|
|
||||||
{ id: "squash", commonName: "Summer Squash", scientificName: "Cucurbita pepo", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, space plants 2-3 feet apart.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg" },
|
|
||||||
{ id: "zucchini", commonName: "Zucchini", scientificName: "Cucurbita pepo var. cylindrica", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), deep consistent watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, harvest when 6-8 inches for best flavor.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg" },
|
|
||||||
{ id: "winter-squash", commonName: "Winter Squash", scientificName: "Cucurbita maxima", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, warm temperatures (65-80°F), well-drained fertile soil, allow to cure on vine before harvest.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Cucurbita_maxima_002.jpg/320px-Cucurbita_maxima_002.jpg" },
|
|
||||||
{ id: "pumpkin", commonName: "Pumpkin", scientificName: "Cucurbita pepo var. maxima", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), deep watering (1-2 inches/week), warm temperatures (65-80°F), well-drained fertile soil, large space requirement (50-100 sq ft per plant).", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/77/Cucurbita_pepo_002.jpg/320px-Cucurbita_pepo_002.jpg" },
|
|
||||||
{ id: "watermelon", commonName: "Watermelon", scientificName: "Citrullus lanatus", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (8h+), consistent deep watering, warm temperatures (75-85°F), well-drained sandy loam soil, trellis for bush varieties.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2a/Citrullus_lanatus_002.jpg/320px-Citrullus_lanatus_002.jpg" },
|
|
||||||
{ id: "cantaloupe", commonName: "Cantaloupe", scientificName: "Cucumis melo var. cantalupo", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (8h+), moderate watering (reduce before harvest), warm temperatures (70-90°F), well-drained fertile soil, mulch with black plastic for warmer soil.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3e/Cucumis_melo_002.jpg/320px-Cucumis_melo_002.jpg" },
|
|
||||||
{ id: "honeydew", commonName: "Honeydew Melon", scientificName: "Cucumis melo var. inodorus", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (8h+), consistent watering, warm temperatures (70-90°F), well-drained fertile soil, allow to ripen on vine until slip ripe.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Cucumis_melo_002.jpg/320px-Cucumis_melo_002.jpg" },
|
|
||||||
{ id: "bitter-melon", commonName: "Bitter Melon", scientificName: "Momordica charantia", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, warm temperatures (70-90°F), trellis support, well-drained fertile soil.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5e/Momordica_charantia_002.jpg/320px-Momordica_charantia_002.jpg" },
|
|
||||||
{ id: "chayote", commonName: "Chayote", scientificName: "Sechium edule", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun to partial shade, moderate watering, warm temperatures (60-80°F), trellis support, well-drained fertile soil.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/Sechium_edule_002.jpg/320px-Sechium_edule_002.jpg" },
|
|
||||||
{ id: "acorn-squash", commonName: "Acorn Squash", scientificName: "Cucurbita pepo var. turbinata", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, warm temperatures (65-80°F), well-drained fertile soil, harvest when skin is hard and deep green.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Cucurbita_pepo_%27Acorn%27.jpg/320px-Cucurbita_pepo_%27Acorn%27.jpg" },
|
|
||||||
{ id: "butternut-squash", commonName: "Butternut Squash", scientificName: "Cucurbita moschata", family: "Cucurbitaceae", category: "vegetable", careSummary: "Full sun (6-8h), deep watering, warm temperatures (65-80°F), well-drained fertile soil, cure 10 days before storage.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Cucurbita_moschata_002.jpg/320px-Cucurbita_moschata_002.jpg" },
|
|
||||||
|
|
||||||
// ── Vegetables (Brassicaceae) ──────────────────────────────────────────
|
|
||||||
{ id: "cabbage", commonName: "Cabbage", scientificName: "Brassica oleracea var. capitata", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent deep watering, cool to moderate temperatures (50-85°F), rich well-drained soil, side-dress with nitrogen mid-season.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Bok_choy.jpg/320px-Bok_choy.jpg" },
|
|
||||||
{ id: "broccoli", commonName: "Broccoli", scientificName: "Brassica oleracea var. italica", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, cool temperatures (50-75°F), rich well-drained soil, harvest central head when tight and dark green.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/59/Broccoli_cabbage_flower.jpg/320px-Broccoli_cabbage_flower.jpg" },
|
|
||||||
{ id: "cauliflower", commonName: "Cauliflower", scientificName: "Brassica oleracea var. botrytis", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, cool temperatures (55-75°F), rich soil, blanch heads by tying leaves over curd.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/Cauliflower_002.jpg/320px-Cauliflower_002.jpg" },
|
|
||||||
{ id: "brussels-sprouts", commonName: "Brussels Sprouts", scientificName: "Brassica oleracea var. gemmifera", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, cool temperatures (50-70°F), rich well-drained soil, harvest from bottom up after light frost.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Brussels_sprouts_002.jpg/320px-Brussels_sprouts_002.jpg" },
|
|
||||||
{ id: "kale", commonName: "Kale", scientificName: "Brassica oleracea var. sabellica", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun to partial shade, consistent watering, cool temperatures (45-75°F), well-drained fertile soil, harvest outer leaves continuously.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Kale_002.jpg/320px-Kale_002.jpg" },
|
|
||||||
{ id: "bok-choy", commonName: "Bok Choy", scientificName: "Brassica rapa var. chinensis", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun to partial shade, consistent moisture, cool temperatures (50-70°F), well-drained fertile soil, harvest when 4-8 inches tall.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Brassica_rapa_002.jpg/320px-Brassica_rapa_002.jpg" },
|
|
||||||
{ id: "radish", commonName: "Radish", scientificName: "Raphanus sativus", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun to partial shade, consistent moisture, cool temperatures (50-70°F), loose well-drained soil, quick maturing (25-30 days).", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ae/Raphanus_sativus_002.jpg/320px-Raphanus_sativus_002.jpg" },
|
|
||||||
{ id: "turnip", commonName: "Turnip", scientificName: "Brassica rapa var. rapa", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, cool temperatures (50-70°F), loose well-drained soil, harvest roots when 1-2 inches diameter.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/Brassica_rapa_002.jpg/320px-Brassica_rapa_002.jpg" },
|
|
||||||
{ id: "arugula", commonName: "Arugula", scientificName: "Eruca vesicaria", family: "Brassicaceae", category: "vegetable", careSummary: "Partial shade to full sun, consistent moisture, cool temperatures (55-65°F), well-drained soil, harvest young leaves for mild flavor.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Eruca_vesicaria_002.jpg/320px-Eruca_vesicaria_002.jpg" },
|
|
||||||
{ id: "collard-greens", commonName: "Collard Greens", scientificName: "Brassica oleracea var. acephala", family: "Brassicaceae", category: "vegetable", careSummary: "Full sun (6-8h), consistent watering, cool temperatures (50-80°F), rich well-drained soil, harvest outer leaves continuously.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Brassica_oleracea_002.jpg/320px-Brassica_oleracea_002.jpg" },
|
|
||||||
|
|
||||||
// ── Vegetables (Fabaceae / Legumes) ────────────────────────────────────
|
|
||||||
{ id: "bean", commonName: "Green Bean", scientificName: "Phaseolus vulgaris", family: "Fabaceae", category: "vegetable", careSummary: "Full sun (6-8h), moderate watering (keep soil evenly moist), warm temperatures (65-80°F), trellis for pole varieties, benefits from nitrogen-fixing roots.", imageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Phaseolus_vulgaris_003.jpg/320px-Phaseolus_v
|
|
||||||
Reference in New Issue
Block a user