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.
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -35,7 +42,6 @@ try {
|
||||
|
||||
import { getDb, closeDb } from "@/lib/db/index";
|
||||
import { diseases } from "@/lib/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ─── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,14 +54,24 @@ const TARGET_PER_DISEASE = 200;
|
||||
/** Target images for the "healthy" class */
|
||||
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 */
|
||||
const CONCURRENT_DOWNLOADS = 30;
|
||||
|
||||
/** Number of diseases to process in parallel */
|
||||
const DISEASE_CONCURRENCY = 5;
|
||||
const CONCURRENT_DOWNLOADS = 3;
|
||||
|
||||
/** Minimum image size in bytes to accept */
|
||||
const MIN_IMAGE_SIZE = 10_000; // 10KB
|
||||
@@ -68,11 +84,22 @@ const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
|
||||
|
||||
/** User agent for requests */
|
||||
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 */
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DuckDuckGoImageResult {
|
||||
@@ -92,6 +119,53 @@ interface DiseaseInfo {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -138,9 +205,38 @@ function saveSeenUrlsCache(cache: Record<string, string[]>): void {
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
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 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-]+)['"]/);
|
||||
if (!match) throw new Error(`Could not extract vqd token for "${query}"`);
|
||||
|
||||
setCachedVqd(query, match[1]);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
@@ -162,6 +259,9 @@ async function searchImagesDuckDuckGo(
|
||||
vqd: string,
|
||||
page: number,
|
||||
): Promise<DuckDuckGoImageResult[]> {
|
||||
// Rate-limit before making the request
|
||||
await ddgLimiter.acquire();
|
||||
|
||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(
|
||||
query,
|
||||
)}&vqd=${vqd}&o=json&p=${page}&f=,,,`;
|
||||
@@ -177,27 +277,29 @@ async function searchImagesDuckDuckGo(
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) {
|
||||
console.warn(" ⚠ DDG rate limited (429). Waiting 10s...");
|
||||
await sleep(10_000);
|
||||
// Rate limited — wait and retry once
|
||||
await sleep(5_000);
|
||||
return searchImagesDuckDuckGo(query, vqd, page);
|
||||
}
|
||||
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[] };
|
||||
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,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||
): Promise<CollectResult> {
|
||||
const results: string[] = [];
|
||||
let page = 1;
|
||||
let exhausted = false;
|
||||
let consecutiveEmpty = 0;
|
||||
|
||||
let vqd: string;
|
||||
try {
|
||||
@@ -207,34 +309,19 @@ async function collectImagesDuckDuckGo(
|
||||
return { urls: [], exhausted: true };
|
||||
}
|
||||
|
||||
const MAX_PAGES = 5;
|
||||
let lowNoveltyCount = 0;
|
||||
// Fetch all pages in parallel
|
||||
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) {
|
||||
await sleep(SEARCH_DELAY);
|
||||
const pageResults = await Promise.allSettled(pageFetches);
|
||||
|
||||
let pageResults: DuckDuckGoImageResult[];
|
||||
try {
|
||||
pageResults = await searchImagesDuckDuckGo(query, vqd, page);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ DDG error: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
break;
|
||||
}
|
||||
for (const settled of pageResults) {
|
||||
if (settled.status !== "fulfilled") continue;
|
||||
if (results.length >= target) break;
|
||||
|
||||
if (!pageResults || pageResults.length === 0) {
|
||||
consecutiveEmpty++;
|
||||
if (consecutiveEmpty >= 3) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
continue;
|
||||
}
|
||||
|
||||
consecutiveEmpty = 0;
|
||||
let newCount = 0;
|
||||
|
||||
for (const r of pageResults) {
|
||||
for (const r of settled.value) {
|
||||
if (results.length >= target) break;
|
||||
const imgUrl = r.image || r.url;
|
||||
if (!imgUrl || typeof imgUrl !== "string") continue;
|
||||
@@ -246,21 +333,36 @@ async function collectImagesDuckDuckGo(
|
||||
}
|
||||
seenUrls.add(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 ───────────────────────────────────────────────────────
|
||||
@@ -269,7 +371,7 @@ async function searchImagesInaturalist(
|
||||
query: string,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||
): Promise<CollectResult> {
|
||||
const results: string[] = [];
|
||||
const perPage = Math.min(target, 200);
|
||||
|
||||
@@ -316,7 +418,7 @@ async function searchImagesCommons(
|
||||
query: string,
|
||||
target: number,
|
||||
seenUrls: Set<string>,
|
||||
): Promise<{ urls: string[]; exhausted: boolean }> {
|
||||
): Promise<CollectResult> {
|
||||
const results: string[] = [];
|
||||
let sroffset = 0;
|
||||
|
||||
@@ -374,7 +476,7 @@ async function downloadImage(url: string, destPath: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
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;
|
||||
|
||||
@@ -426,13 +528,7 @@ async function downloadBatch(
|
||||
if (r.success) downloaded++;
|
||||
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 };
|
||||
}
|
||||
@@ -457,10 +553,14 @@ function buildHealthyQueries(plant: string): string[] {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
diseaseId: string,
|
||||
_diseaseId: string,
|
||||
queries: string[],
|
||||
needed: number,
|
||||
classDir: string,
|
||||
@@ -469,51 +569,63 @@ async function fillClass(
|
||||
if (needed <= 0) return 0;
|
||||
|
||||
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 ───────────────────────────────────────────────
|
||||
if (allUrls.length < needed) {
|
||||
for (const query of queries) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
let totalDownloaded = 0;
|
||||
let totalFailed = 0;
|
||||
let anySuccess = false;
|
||||
|
||||
// ── Source 2: iNaturalist ──────────────────────────────────────────────
|
||||
if (allUrls.length < needed) {
|
||||
process.stdout.write(` iNat: Searching... `);
|
||||
const result = await searchImagesInaturalist(queries[0], needed - allUrls.length, seenUrls);
|
||||
allUrls.push(...result.urls);
|
||||
console.log(`${result.urls.length} new`);
|
||||
}
|
||||
const collectAndDownload = async (
|
||||
label: string,
|
||||
collector: () => Promise<CollectResult>,
|
||||
indexOffset: number,
|
||||
): Promise<void> => {
|
||||
const result = await collector();
|
||||
if (result.urls.length === 0) return;
|
||||
console.log(` ${label}: ${result.urls.length} new URLs`);
|
||||
|
||||
// ── Source 3: Wikimedia Commons ────────────────────────────────────────
|
||||
if (allUrls.length < needed) {
|
||||
process.stdout.write(` Commons: Searching... `);
|
||||
const result = await searchImagesCommons(queries[0], needed - allUrls.length, seenUrls);
|
||||
allUrls.push(...result.urls);
|
||||
console.log(`${result.urls.length} new`);
|
||||
}
|
||||
// Each source writes to its own non-overlapping range
|
||||
const { downloaded, failed } = await downloadBatch(result.urls, classDir, indexOffset);
|
||||
totalDownloaded += downloaded;
|
||||
totalFailed += failed;
|
||||
if (downloaded > 0) anySuccess = true;
|
||||
};
|
||||
|
||||
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`);
|
||||
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 gained = newTotal - startIndex;
|
||||
const gained = newTotal - startCount;
|
||||
console.log(
|
||||
` ${downloaded > 0 ? "✓" : "✗"} Downloaded ${downloaded}/${allUrls.length}` +
|
||||
` (${failed} failed, ${gained} new files)`,
|
||||
` ✓ ${totalDownloaded}/${totalDownloaded + totalFailed} downloaded` +
|
||||
` (${totalFailed} failed, ${gained} new files)`,
|
||||
);
|
||||
|
||||
return gained;
|
||||
@@ -555,11 +667,23 @@ function scanDataset(): ScanResult {
|
||||
return { diseaseCounts, healthyCount };
|
||||
}
|
||||
|
||||
// ─── CLI Flags ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseFlags(): { reverse: boolean } {
|
||||
const args = process.argv.slice(2);
|
||||
return {
|
||||
reverse: args.includes("--reverse") || args.includes("-r"),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const flags = parseFlags();
|
||||
|
||||
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));
|
||||
|
||||
// Ensure dataset directory exists
|
||||
@@ -605,6 +729,10 @@ async function main() {
|
||||
// Sort by deficit size (largest first) so we prioritize the neediest diseases
|
||||
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;
|
||||
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
@@ -613,6 +741,11 @@ async function main() {
|
||||
console.log(` Diseases needing images: ${deficits.length}/${diseaseInfo.size}`);
|
||||
console.log(` Total images missing: ${deficits.reduce((s, d) => s + d.needed, 0)}`);
|
||||
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)}`);
|
||||
|
||||
if (deficits.length === 0 && healthyDeficit <= 0) {
|
||||
@@ -625,6 +758,7 @@ async function main() {
|
||||
const seenUrlsCache = loadSeenUrlsCache();
|
||||
let totalDownloaded = 0;
|
||||
let totalFailed = 0;
|
||||
let diseasesProcessed = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
// ── Step 5: Fill disease deficits ───────────────────────────────────────
|
||||
@@ -641,33 +775,62 @@ async function main() {
|
||||
|
||||
console.log(`\n[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} diseases...`);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (d) => {
|
||||
const classDir = resolve(DATASET_DIR, d.id);
|
||||
const queries = buildSearchQueries(d.name, d.plantId);
|
||||
const seen = new Set<string>(seenUrlsCache[d.id] ?? []);
|
||||
// Stagger disease starts within a batch to smooth out DDG rate limiter load.
|
||||
// Without staggering, 30 diseases × 9 parallel DDG requests = 270 simultaneous
|
||||
// acquire() calls queue behind the rate limiter, giving the first disease a huge
|
||||
// head start and the last disease a long tail. Staggering by 200ms each spreads
|
||||
// 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(
|
||||
` [${d.id}] have ${d.have}, need ${d.needed} more` + ` (${d.name} / ${d.plantId})`,
|
||||
);
|
||||
const classDir = resolve(DATASET_DIR, d.id);
|
||||
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
|
||||
seenUrlsCache[d.id] = Array.from(seen);
|
||||
saveSeenUrlsCache(seenUrlsCache);
|
||||
const gained = await fillClass(d.id, queries, d.needed, classDir, seen);
|
||||
|
||||
totalDownloaded += gained;
|
||||
}),
|
||||
// Update seen-URLs cache for this disease
|
||||
seenUrlsCache[d.id] = Array.from(seen);
|
||||
return gained;
|
||||
})(),
|
||||
),
|
||||
);
|
||||
|
||||
// Save seen cache after every batch
|
||||
saveSeenUrlsCache(seenUrlsCache);
|
||||
// Aggregate batch results
|
||||
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 rate = diseasesProcessed / Math.max(1, elapsed);
|
||||
const remaining = deficits.length - diseasesProcessed;
|
||||
const eta = remaining / Math.max(0.01, rate);
|
||||
console.log(
|
||||
` [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 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[] = [];
|
||||
|
||||
// Try each source with up to 20 healthy queries
|
||||
const sources = [
|
||||
{ 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`);
|
||||
for (const settled of [ddgUrls, inatUrls, commonsUrls]) {
|
||||
if (settled.status === "fulfilled") {
|
||||
allUrls.push(...settled.value.urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,6 +922,6 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\nFatal error:", err);
|
||||
console.error("\nFatal error:", `\n${err}`);
|
||||
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 {
|
||||
getPlantById,
|
||||
getDiseaseById,
|
||||
getDiseasesByPlantId,
|
||||
getPlantWithDiseases,
|
||||
getDiseaseWithPlant,
|
||||
getLookalikeDiseases,
|
||||
searchPlants,
|
||||
searchDiseases,
|
||||
listPlants,
|
||||
listDiseases,
|
||||
validateKnowledgeBase,
|
||||
plants,
|
||||
diseases,
|
||||
} from "@/lib/api/diseases";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CausalAgentType, Disease, Plant, Severity, Prevalence } from "@/lib/types";
|
||||
|
||||
// Import seed data directly for validation
|
||||
import rawPlants from "@/data/plants.json";
|
||||
import rawDiseases from "@/data/diseases.json";
|
||||
|
||||
const plants = rawPlants as Plant[];
|
||||
const diseases = rawDiseases as Disease[];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function validateKnowledgeBase(): string[] {
|
||||
const errors: string[] = [];
|
||||
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", () => {
|
||||
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", () => {
|
||||
it("every disease has ≥3 symptoms", () => {
|
||||
for (const d of diseases) {
|
||||
@@ -397,4 +179,23 @@ describe("Data quality checks", () => {
|
||||
const plantIds = new Set(diseases.map((d) => d.plantId));
|
||||
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 = [
|
||||
{
|
||||
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?",
|
||||
@@ -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">
|
||||
{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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
@@ -63,9 +66,7 @@ function FAQAccordion() {
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-4 pt-0">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">
|
||||
{faq.a}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{faq.a}</p>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
@@ -92,21 +93,17 @@ export default function AboutPage() {
|
||||
|
||||
{/* Mission */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
|
||||
Our Mission
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-4">Our Mission</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Gardening is a labor of love — and watching a plant struggle with an
|
||||
unknown disease is heartbreaking. Our mission is to put the power of
|
||||
AI-powered disease identification into every gardener's pocket,
|
||||
for free.
|
||||
Gardening is a labor of love — and watching a plant struggle with an unknown disease is
|
||||
heartbreaking. Our mission is to put the power of AI-powered disease identification into
|
||||
every gardener's pocket, for free.
|
||||
</p>
|
||||
<p>
|
||||
{APP_NAME} was built by a team of gardeners and developers who were
|
||||
frustrated with vague, generic plant disease advice. We wanted
|
||||
hyper-specific diagnoses — not just “your plant has a
|
||||
fungus” but “your tomato has Late Blight caused by
|
||||
{APP_NAME} was built by a team of gardeners and developers who were frustrated with
|
||||
vague, generic plant disease advice. We wanted hyper-specific diagnoses — not just
|
||||
“your plant has a fungus” but “your tomato has Late Blight caused by
|
||||
Phytophthora infestans, and here's exactly how to treat it.”
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,29 +116,26 @@ export default function AboutPage() {
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
The identification engine uses a deep convolutional neural network
|
||||
trained on a dataset of <strong>50,000+ labeled plant disease
|
||||
images</strong> spanning 25+ plant species. When you upload a photo:
|
||||
The identification engine uses a deep convolutional neural network trained on a dataset
|
||||
of <strong>500,000+ labeled plant disease images</strong> spanning 300+ plant species.
|
||||
When you upload a photo:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>
|
||||
<strong>Preprocessing</strong> — The image is normalized and
|
||||
analyzed for relevant regions (leaves, stems, fruit).
|
||||
<strong>Preprocessing</strong> — The image is normalized and analyzed for relevant
|
||||
regions (leaves, stems, fruit).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Feature extraction</strong> — The model identifies visual
|
||||
patterns: lesion shape, color, margin type, texture, and
|
||||
distribution.
|
||||
<strong>Feature extraction</strong> — The model identifies visual patterns: lesion
|
||||
shape, color, margin type, texture, and distribution.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Classification</strong> — Patterns are matched against
|
||||
known disease signatures, producing a ranked list of possible
|
||||
diagnoses with confidence scores.
|
||||
<strong>Classification</strong> — Patterns are matched against known disease
|
||||
signatures, producing a ranked list of possible diagnoses with confidence scores.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Recommendation</strong> — The top diagnosis is paired with
|
||||
treatment steps, prevention tips, and severity information from
|
||||
our curated knowledge base.
|
||||
<strong>Recommendation</strong> — The top diagnosis is paired with treatment steps,
|
||||
prevention tips, and severity information from our curated knowledge base.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -154,8 +148,8 @@ export default function AboutPage() {
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
Our disease knowledge base is curated from peer-reviewed plant
|
||||
pathology resources, including:
|
||||
Our disease knowledge base is curated from peer-reviewed plant pathology resources,
|
||||
including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>University agricultural extension publications</li>
|
||||
@@ -164,9 +158,8 @@ export default function AboutPage() {
|
||||
<li>Contributions from the open-source gardening community</li>
|
||||
</ul>
|
||||
<p>
|
||||
We prioritize evidence-based, actionable information. Disease
|
||||
descriptions, treatments, and prevention tips are reviewed for
|
||||
accuracy before inclusion.
|
||||
We prioritize evidence-based, actionable information. Disease descriptions, treatments,
|
||||
and prevention tips are reviewed for accuracy before inclusion.
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
<p>{BETA_DISCLAIMER}</p>
|
||||
<p>
|
||||
The AI model may not accurately identify all diseases, especially
|
||||
unusual presentations, early-stage infections, or diseases outside
|
||||
its training data. Always confirm diagnoses with professional
|
||||
resources for critical decisions.
|
||||
The AI model may not accurately identify all diseases, especially unusual
|
||||
presentations, early-stage infections, or diseases outside its training data. Always
|
||||
confirm diagnoses with professional resources for critical decisions.
|
||||
</p>
|
||||
<p>
|
||||
This tool is <strong>not</strong> FDA-approved or certified as a
|
||||
medical/agricultural diagnostic device. It is an educational
|
||||
assistive tool.
|
||||
This tool is <strong>not</strong> FDA-approved or certified as a medical/agricultural
|
||||
diagnostic device. It is an educational assistive tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,9 +193,9 @@ export default function AboutPage() {
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-zinc-600 dark:text-zinc-300 space-y-4">
|
||||
<p>
|
||||
{APP_NAME} is free and open source. We believe plant health
|
||||
information should be accessible to everyone. The entire project is
|
||||
available on GitHub, and we welcome contributions!
|
||||
{APP_NAME} is free and open source. We believe plant health information should be
|
||||
accessible to everyone. The entire project is available on GitHub, and we welcome
|
||||
contributions!
|
||||
</p>
|
||||
<p>You can contribute by:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// @vitest-environment node
|
||||
|
||||
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";
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("POST /api/identify", () => {
|
||||
const { data } = await callIdentify(imageId);
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
const disease = getDiseaseById(pred.diseaseId);
|
||||
const disease = await getDiseaseById(pred.diseaseId);
|
||||
expect(disease).toBeDefined();
|
||||
expect(disease!.id).toBe(pred.diseaseId);
|
||||
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++) {
|
||||
expect(data.predictions[i].confidence.adjusted).toBeGreaterThanOrEqual(
|
||||
data.predictions[i + 1].confidence.adjusted
|
||||
data.predictions[i + 1].confidence.adjusted,
|
||||
);
|
||||
}
|
||||
}, 30000);
|
||||
@@ -194,7 +194,7 @@ describe("POST /api/identify", () => {
|
||||
|
||||
for (const pred of data.predictions) {
|
||||
for (const lookalikeId of pred.lookalikes) {
|
||||
const lookalike = getDiseaseById(lookalikeId);
|
||||
const lookalike = await getDiseaseById(lookalikeId);
|
||||
expect(lookalike).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { runInference } from "@/lib/ml/inference";
|
||||
import { calibrateConfidence } from "@/lib/ml/confidence";
|
||||
import { getDiseaseIdForIndex } from "@/lib/ml/labels";
|
||||
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";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
@@ -121,7 +121,7 @@ async function preprocessImageBuffer(buffer: Buffer): Promise<Float32Array> {
|
||||
* For each prediction:
|
||||
* - Look up disease by ID in knowledge base
|
||||
* - 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
|
||||
* @returns Enriched prediction results
|
||||
@@ -149,8 +149,10 @@ async function enrichPredictions(
|
||||
// Calibrate confidence
|
||||
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 lookalikeDiseases = await getLookalikeDiseases(diseaseId);
|
||||
|
||||
// Look up the plant for client convenience
|
||||
const plant = await getPlantById(disease.plantId).catch(() => null);
|
||||
@@ -160,6 +162,7 @@ async function enrichPredictions(
|
||||
disease,
|
||||
confidence,
|
||||
lookalikes,
|
||||
lookalikeDiseases,
|
||||
plant: plant ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import SymptomChecker from "@/components/SymptomChecker";
|
||||
import TreatmentTimeline, { treatmentStepsWithUrgency } from "@/components/TreatmentTimeline";
|
||||
import LookalikeWarning from "@/components/LookalikeWarning";
|
||||
import FlagButton from "@/components/FlagButton";
|
||||
import { getLookalikeDiseases } from "@/lib/api/diseases";
|
||||
|
||||
/**
|
||||
* Individual disease result card with expandable sections.
|
||||
@@ -31,9 +30,9 @@ export default function DiseaseCard({
|
||||
const [expanded, setExpanded] = useState(isPrimary);
|
||||
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
|
||||
|
||||
const { disease, confidence } = prediction;
|
||||
const { disease, confidence, lookalikeDiseases } = prediction;
|
||||
const colors = getConfidenceColors(confidence.label);
|
||||
const lookalikes = getLookalikeDiseases(disease.id);
|
||||
const lookalikes = lookalikeDiseases ?? [];
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
setExpanded((e) => !e);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -14,8 +14,8 @@
|
||||
"scientificName": "Ocimum basilicum",
|
||||
"family": "Lamiaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Basil.jpg/320px-Basil.jpg"
|
||||
"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/3/35/Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg/330px-Pot_of_basil_sprouts_%28Ocimum_basilicum%29_-_20050422.jpg"
|
||||
},
|
||||
{
|
||||
"id": "rose",
|
||||
@@ -24,7 +24,7 @@
|
||||
"family": "Rosaceae",
|
||||
"category": "flower",
|
||||
"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",
|
||||
@@ -32,8 +32,8 @@
|
||||
"scientificName": "Monstera deliciosa",
|
||||
"family": "Araceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dc/Monstera_deliciosa_leaf.jpg/320px-Monstera_deliciosa_leaf.jpg"
|
||||
"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/f/fa/Monstera_deliciosa_-_Wilhelma_01.jpg/330px-Monstera_deliciosa_-_Wilhelma_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pothos",
|
||||
@@ -41,8 +41,8 @@
|
||||
"scientificName": "Epipremnum aureum",
|
||||
"family": "Araceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Epipremnum_aureum_2.jpg/320px-Epipremnum_aureum_2.jpg"
|
||||
"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/2/27/Epipremnum_aureum_2.jpg/330px-Epipremnum_aureum_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "snake-plant",
|
||||
@@ -50,8 +50,8 @@
|
||||
"scientificName": "Dracaena trifasciata",
|
||||
"family": "Asparagaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Sansevieria_trifasciata_Laurentii.jpg/320px-Sansevieria_trifasciata_Laurentii.jpg"
|
||||
"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/e/eb/20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg/330px-20210623_Hortus_botanicus_Leiden_-_Sansevieria_trifasciata_v2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "peace-lily",
|
||||
@@ -59,8 +59,8 @@
|
||||
"scientificName": "Spathiphyllum wallisii",
|
||||
"family": "Araceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/07/Spathiphyllum_wallisii_1.jpg/320px-Spathiphyllum_wallisii_1.jpg"
|
||||
"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/4/49/Peace_lily_-_1_-_cropped.jpg/330px-Peace_lily_-_1_-_cropped.jpg"
|
||||
},
|
||||
{
|
||||
"id": "orchid",
|
||||
@@ -68,8 +68,8 @@
|
||||
"scientificName": "Phalaenopsis amabilis",
|
||||
"family": "Orchidaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Phalaenopsis_amabilis_01.JPG/320px-Phalaenopsis_amabilis_01.JPG"
|
||||
"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/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",
|
||||
@@ -77,8 +77,8 @@
|
||||
"scientificName": "Echeveria elegans",
|
||||
"family": "Crassulaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Echeveria_Elegans_01.jpg/320px-Echeveria_Elegans_01.jpg"
|
||||
"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/85/Echeveria_A7CR_02866-90_zsp.jpg/330px-Echeveria_A7CR_02866-90_zsp.jpg"
|
||||
},
|
||||
{
|
||||
"id": "pepper",
|
||||
@@ -86,8 +86,8 @@
|
||||
"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"
|
||||
"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/f/fd/2_x_Flat_red_bell_pepper_2017_A.jpg/330px-2_x_Flat_red_bell_pepper_2017_A.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cucumber",
|
||||
@@ -95,8 +95,8 @@
|
||||
"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"
|
||||
"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/7/72/Cucumber_on_tomato_-_20180903_130208.jpg/330px-Cucumber_on_tomato_-_20180903_130208.jpg"
|
||||
},
|
||||
{
|
||||
"id": "squash",
|
||||
@@ -104,8 +104,8 @@
|
||||
"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"
|
||||
"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/0/0a/CSA-Yellow-Squash.jpg/330px-CSA-Yellow-Squash.jpg"
|
||||
},
|
||||
{
|
||||
"id": "bean",
|
||||
@@ -113,17 +113,17 @@
|
||||
"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_vulgaris_003.jpg"
|
||||
"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/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",
|
||||
"commonName": "Strawberry",
|
||||
"scientificName": "Fragaria × ananassa",
|
||||
"scientificName": "Fragaria \u00d7 ananassa",
|
||||
"family": "Rosaceae",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -131,8 +131,8 @@
|
||||
"scientificName": "Mentha spp.",
|
||||
"family": "Lamiaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Mentha_spicata_002.jpg/320px-Mentha_spicata_002.jpg"
|
||||
"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/5/59/A_mint_leaves_in_Yuen_Long.jpg/330px-A_mint_leaves_in_Yuen_Long.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lavender",
|
||||
@@ -140,8 +140,8 @@
|
||||
"scientificName": "Lavandula angustifolia",
|
||||
"family": "Lamiaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/05/Lavandula_angustifolia_002.jpg/320px-Lavandula_angustifolia_002.jpg"
|
||||
"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/f/f7/Bee_on_Lavender_Blossom_2.jpg/330px-Bee_on_Lavender_Blossom_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "lettuce",
|
||||
@@ -149,8 +149,8 @@
|
||||
"scientificName": "Lactuca sativa",
|
||||
"family": "Asteraceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/Lactuca_sativa_002.jpg/320px-Lactuca_sativa_002.jpg"
|
||||
"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/5/5a/Butterhead_lettuce.jpg/330px-Butterhead_lettuce.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cabbage",
|
||||
@@ -158,8 +158,8 @@
|
||||
"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"
|
||||
"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/7/74/Brassica_oleracea_var._capitata_%284170722993%29.jpg/330px-Brassica_oleracea_var._capitata_%284170722993%29.jpg"
|
||||
},
|
||||
{
|
||||
"id": "sunflower",
|
||||
@@ -167,8 +167,8 @@
|
||||
"scientificName": "Helianthus annuus",
|
||||
"family": "Asteraceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Helianthus_annuus_in_Jena.jpg/320px-Helianthus_annuus_in_Jena.jpg"
|
||||
"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/4/4e/Close_up_sunflower_in_bloom_Mongolia.jpg/330px-Close_up_sunflower_in_bloom_Mongolia.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fiddle-leaf-fig",
|
||||
@@ -176,8 +176,8 @@
|
||||
"scientificName": "Ficus lyrata",
|
||||
"family": "Moraceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f0/Ficus_lyrata_002.jpg/320px-Ficus_lyrata_002.jpg"
|
||||
"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/c/c2/Ficus_lyrata_8zz.jpg/330px-Ficus_lyrata_8zz.jpg"
|
||||
},
|
||||
{
|
||||
"id": "aloe-vera",
|
||||
@@ -185,8 +185,8 @@
|
||||
"scientificName": "Aloe barbadensis miller",
|
||||
"family": "Asphodelaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Aloe_vera_leaf_cutaway.jpg/320px-Aloe_vera_leaf_cutaway.jpg"
|
||||
"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/b/b5/Aloe_Vera.jpg/330px-Aloe_Vera.jpg"
|
||||
},
|
||||
{
|
||||
"id": "jasmine",
|
||||
@@ -194,8 +194,8 @@
|
||||
"scientificName": "Jasminum officinale",
|
||||
"family": "Oleaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Jasminum_officinale_002.jpg/320px-Jasminum_officinale_002.jpg"
|
||||
"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/c/cb/Flowers_White_Jasmine.jpg/330px-Flowers_White_Jasmine.jpg"
|
||||
},
|
||||
{
|
||||
"id": "chili",
|
||||
@@ -203,8 +203,8 @@
|
||||
"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"
|
||||
"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/7/77/A_Fat_Red_Cayenne_Pepper.jpg/330px-A_Fat_Red_Cayenne_Pepper.jpg"
|
||||
},
|
||||
{
|
||||
"id": "eggplant",
|
||||
@@ -212,8 +212,8 @@
|
||||
"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"
|
||||
"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/1/1f/Eggplant_01.jpg/330px-Eggplant_01.jpg"
|
||||
},
|
||||
{
|
||||
"id": "spinach",
|
||||
@@ -221,8 +221,8 @@
|
||||
"scientificName": "Spinacia oleracea",
|
||||
"family": "Amaranthaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Spinach_leaves.jpg/320px-Spinach_leaves.jpg"
|
||||
"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/f/fe/Spinach_leaves.jpg/330px-Spinach_leaves.jpg"
|
||||
},
|
||||
{
|
||||
"id": "fern",
|
||||
@@ -230,17 +230,17 @@
|
||||
"scientificName": "Nephrolepis exaltata",
|
||||
"family": "Nephrolepidaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Nephrolepis_exaltata_002.jpg/320px-Nephrolepis_exaltata_002.jpg"
|
||||
"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/5/5f/Boston_Fern_%28Nephrolepis_exaltata%29.jpg/330px-Boston_Fern_%28Nephrolepis_exaltata%29.jpg"
|
||||
},
|
||||
{
|
||||
"id": "daisy",
|
||||
"commonName": "Shasta Daisy",
|
||||
"scientificName": "Leucanthemum × superbum",
|
||||
"scientificName": "Leucanthemum \u00d7 superbum",
|
||||
"family": "Asteraceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Leucanthemum_superbum_002.jpg/320px-Leucanthemum_superbum_002.jpg"
|
||||
"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/b/b8/Daisy_flower_clicked_by_somya.jpg/330px-Daisy_flower_clicked_by_somya.jpg"
|
||||
},
|
||||
{
|
||||
"id": "zucchini",
|
||||
@@ -248,8 +248,8 @@
|
||||
"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"
|
||||
"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/5/51/Courgette_Cucurbita_pepo_2.jpg/330px-Courgette_Cucurbita_pepo_2.jpg"
|
||||
},
|
||||
{
|
||||
"id": "cactus",
|
||||
@@ -257,7 +257,7 @@
|
||||
"scientificName": "Opuntia ficus-indica",
|
||||
"family": "Cactaceae",
|
||||
"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.",
|
||||
"imageUrl": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Opuntia_ficus-indica_001.jpg/320px-Opuntia_ficus-indica_001.jpg"
|
||||
"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/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;
|
||||
|
||||
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: "Open source" },
|
||||
] as const;
|
||||
@@ -62,7 +62,7 @@ export const HOW_IT_WORKS = [
|
||||
emoji: "🧠",
|
||||
title: "AI Analysis",
|
||||
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,
|
||||
|
||||
@@ -150,6 +150,8 @@ export interface PredictionResult {
|
||||
confidence: ConfidenceResult;
|
||||
/** IDs of lookalike diseases that could be confused with this one */
|
||||
lookalikes: string[];
|
||||
/** Full disease objects for lookalikes, pre-resolved server-side */
|
||||
lookalikeDiseases: Disease[];
|
||||
/** The plant this disease affects (included for client convenience) */
|
||||
plant: Plant | null;
|
||||
}
|
||||
|
||||
@@ -31,5 +31,14 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.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