diff --git a/apps/web/.vercelignore b/apps/web/.vercelignore index 37cddb8..2780a99 100644 --- a/apps/web/.vercelignore +++ b/apps/web/.vercelignore @@ -17,5 +17,9 @@ coverage/ # Git (Vercel prefers no git dir for CLI deploys) .git/ +# Scripts and task files (build-time only) +scripts/ +tasks/ + # OS files .DS_Store diff --git a/apps/web/scripts/fill-training-dataset.ts b/apps/web/scripts/fill-training-dataset.ts index 8757455..9db9b4e 100644 --- a/apps/web/scripts/fill-training-dataset.ts +++ b/apps/web/scripts/fill-training-dataset.ts @@ -59,7 +59,7 @@ const TARGET_HEALTHY = 400; * 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 = 30; +const DISEASE_CONCURRENCY = 60; /** * Max DDG requests per second (shared across all concurrent diseases). @@ -68,10 +68,10 @@ const DISEASE_CONCURRENCY = 30; * 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 = 15; +const DDG_RATE_LIMIT_RPS = 3; /** Max concurrent image downloads per disease */ -const CONCURRENT_DOWNLOADS = 30; +const CONCURRENT_DOWNLOADS = 3; /** Minimum image size in bytes to accept */ const MIN_IMAGE_SIZE = 10_000; // 10KB @@ -84,7 +84,7 @@ 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"; diff --git a/apps/web/src/__tests__/diseases.test.ts b/apps/web/src/__tests__/diseases.test.ts index 5237115..daeda4a 100644 --- a/apps/web/src/__tests__/diseases.test.ts +++ b/apps/web/src/__tests__/diseases.test.ts @@ -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(); + 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(); + 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(); + } + }); }); diff --git a/apps/web/src/app/api/identify/identify.test.ts b/apps/web/src/app/api/identify/identify.test.ts index 589585a..d74a042 100644 --- a/apps/web/src/app/api/identify/identify.test.ts +++ b/apps/web/src/app/api/identify/identify.test.ts @@ -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(); } } diff --git a/apps/web/src/app/api/identify/route.ts b/apps/web/src/app/api/identify/route.ts index b16e0f2..52cb204 100644 --- a/apps/web/src/app/api/identify/route.ts +++ b/apps/web/src/app/api/identify/route.ts @@ -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 { * 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, }); } diff --git a/apps/web/src/components/DiseaseCard.tsx b/apps/web/src/components/DiseaseCard.tsx index d6cf40c..41231a7 100644 --- a/apps/web/src/components/DiseaseCard.tsx +++ b/apps/web/src/components/DiseaseCard.tsx @@ -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); diff --git a/apps/web/src/lib/api/diseases.ts b/apps/web/src/lib/api/diseases.ts deleted file mode 100644 index b832e68..0000000 --- a/apps/web/src/lib/api/diseases.ts +++ /dev/null @@ -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 { - const ids = new Set(); - 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(); - 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(); - 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; -} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 3a4d3a5..329e81d 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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; } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b5da6f7..5bf2b73 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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/**" + ] }