Merge: Grower Advisory Agent with tests and type fixes - resolved conflicts

This commit is contained in:
Vinnie Esposito 2025-11-23 11:02:33 -06:00
commit 0fcecca424
17 changed files with 1154 additions and 197 deletions

View file

@ -0,0 +1,215 @@
/**
* GrowerAdvisoryAgent Tests
* Tests for the grower advisory and recommendation system
*/
import {
GrowerAdvisoryAgent,
getGrowerAdvisoryAgent,
} from '../../../lib/agents/GrowerAdvisoryAgent';
describe('GrowerAdvisoryAgent', () => {
let agent: GrowerAdvisoryAgent;
beforeEach(() => {
agent = new GrowerAdvisoryAgent();
});
describe('Initialization', () => {
it('should create agent with correct configuration', () => {
expect(agent.config.id).toBe('grower-advisory-agent');
expect(agent.config.name).toBe('Grower Advisory Agent');
expect(agent.config.enabled).toBe(true);
expect(agent.config.priority).toBe('high');
});
it('should have correct interval (5 minutes)', () => {
expect(agent.config.intervalMs).toBe(300000);
});
it('should start in idle status', () => {
expect(agent.status).toBe('idle');
});
it('should have empty metrics initially', () => {
const metrics = agent.getMetrics();
expect(metrics.tasksCompleted).toBe(0);
expect(metrics.tasksFailed).toBe(0);
expect(metrics.errors).toEqual([]);
});
});
describe('Grower Profile Management', () => {
it('should register a grower profile', () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const retrieved = agent.getGrowerProfile('grower-1');
expect(retrieved).not.toBeNull();
expect(retrieved?.growerId).toBe('grower-1');
});
it('should return null for unknown grower', () => {
const retrieved = agent.getGrowerProfile('unknown-grower');
expect(retrieved).toBeNull();
});
it('should update existing profile', () => {
const profile1 = createGrowerProfile('grower-1');
profile1.experienceLevel = 'beginner';
agent.registerGrowerProfile(profile1);
const profile2 = createGrowerProfile('grower-1');
profile2.experienceLevel = 'expert';
agent.registerGrowerProfile(profile2);
const retrieved = agent.getGrowerProfile('grower-1');
expect(retrieved?.experienceLevel).toBe('expert');
});
});
describe('Recommendations', () => {
it('should return empty recommendations for unknown grower', () => {
const recs = agent.getRecommendations('unknown-grower');
expect(recs).toEqual([]);
});
it('should get recommendations after profile registration', () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
// Recommendations are generated during runOnce
const recs = agent.getRecommendations('grower-1');
expect(Array.isArray(recs)).toBe(true);
});
});
describe('Rotation Advice', () => {
it('should return null for unknown grower', () => {
const advice = agent.getRotationAdvice('unknown-grower');
expect(advice).toBeNull();
});
});
describe('Market Opportunities', () => {
it('should return array of opportunities', () => {
const opps = agent.getOpportunities();
expect(Array.isArray(opps)).toBe(true);
});
});
describe('Grower Performance', () => {
it('should return null for unknown grower', () => {
const perf = agent.getPerformance('unknown-grower');
expect(perf).toBeNull();
});
});
describe('Seasonal Alerts', () => {
it('should return array of seasonal alerts', () => {
const alerts = agent.getSeasonalAlerts();
expect(Array.isArray(alerts)).toBe(true);
});
});
describe('Agent Lifecycle', () => {
it('should start and change status to running', async () => {
await agent.start();
expect(agent.status).toBe('running');
await agent.stop();
});
it('should stop and change status to idle', async () => {
await agent.start();
await agent.stop();
expect(agent.status).toBe('idle');
});
it('should pause when running', async () => {
await agent.start();
agent.pause();
expect(agent.status).toBe('paused');
await agent.stop();
});
it('should resume after pause', async () => {
await agent.start();
agent.pause();
agent.resume();
expect(agent.status).toBe('running');
await agent.stop();
});
});
describe('Singleton', () => {
it('should return same instance from getGrowerAdvisoryAgent', () => {
const agent1 = getGrowerAdvisoryAgent();
const agent2 = getGrowerAdvisoryAgent();
expect(agent1).toBe(agent2);
});
});
describe('Alerts', () => {
it('should return alerts array', () => {
const alerts = agent.getAlerts();
expect(Array.isArray(alerts)).toBe(true);
});
});
describe('Task Execution', () => {
it('should execute runOnce successfully', async () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const result = await agent.runOnce();
expect(result).not.toBeNull();
expect(result?.status).toBe('completed');
expect(result?.type).toBe('grower_advisory');
});
it('should report metrics in task result', async () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const result = await agent.runOnce();
expect(result?.result).toHaveProperty('growersAdvised');
expect(result?.result).toHaveProperty('recommendationsGenerated');
expect(result?.result).toHaveProperty('opportunitiesIdentified');
expect(result?.result).toHaveProperty('alertsGenerated');
});
it('should count registered growers', async () => {
agent.registerGrowerProfile(createGrowerProfile('grower-1'));
agent.registerGrowerProfile(createGrowerProfile('grower-2'));
agent.registerGrowerProfile(createGrowerProfile('grower-3'));
const result = await agent.runOnce();
expect(result?.result?.growersAdvised).toBe(3);
});
});
});
// Helper function to create test grower profiles
function createGrowerProfile(
growerId: string,
lat: number = 40.7128,
lon: number = -74.006
) {
return {
growerId,
growerName: `Test Grower ${growerId}`,
location: { latitude: lat, longitude: lon },
availableSpaceSqm: 100,
specializations: ['lettuce', 'tomato'],
certifications: ['organic'],
experienceLevel: 'intermediate' as const,
preferredCrops: ['lettuce', 'tomato', 'basil'],
growingHistory: [
{ cropType: 'lettuce', successRate: 85, avgYield: 4.5 },
{ cropType: 'tomato', successRate: 75, avgYield: 8.0 },
],
};
}

View file

@ -32,7 +32,7 @@ export default function EnvironmentalForm({
onChange({
...value,
[section]: {
...currentSection,
...(currentSection as object || {}),
...updates,
},
});

View file

@ -168,7 +168,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const chain = blockchain.chain;
const plants = chain.slice(1); // Skip genesis
let profilesUpdated = 0;
@ -265,9 +265,9 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
for (const block of healthyPlants) {
const env = block.plant.environment;
if (env?.soil?.pH) pHValues.push(env.soil.pH);
if (env?.climate?.avgTemperature) tempValues.push(env.climate.avgTemperature);
if (env?.climate?.avgHumidity) humidityValues.push(env.climate.avgHumidity);
if (env?.lighting?.hoursPerDay) lightValues.push(env.lighting.hoursPerDay);
if (env?.climate?.temperatureDay) tempValues.push(env.climate.temperatureDay);
if (env?.climate?.humidityAverage) humidityValues.push(env.climate.humidityAverage);
if (env?.lighting?.naturalLight?.hoursPerDay) lightValues.push(env.lighting.naturalLight.hoursPerDay);
}
const profile: EnvironmentProfile = existing || {
@ -357,15 +357,16 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Lighting analysis
if (env.lighting) {
const lightDiff = env.lighting.hoursPerDay
? Math.abs(env.lighting.hoursPerDay - profile.optimalConditions.lightHours.optimal)
const lightHours = env.lighting.naturalLight?.hoursPerDay || env.lighting.artificialLight?.hoursPerDay;
const lightDiff = lightHours
? Math.abs(lightHours - profile.optimalConditions.lightHours.optimal)
: 2;
lightingScore = Math.max(0, 100 - lightDiff * 15);
if (lightDiff > 2) {
improvements.push({
category: 'lighting',
currentState: `${env.lighting.hoursPerDay || 'unknown'} hours/day`,
currentState: `${lightHours || 'unknown'} hours/day`,
recommendedState: `${profile.optimalConditions.lightHours.optimal} hours/day`,
priority: lightDiff > 4 ? 'high' : 'medium',
expectedImpact: 'Better photosynthesis and growth',
@ -376,11 +377,11 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Climate analysis
if (env.climate) {
const tempDiff = env.climate.avgTemperature
? Math.abs(env.climate.avgTemperature - profile.optimalConditions.temperature.optimal)
const tempDiff = env.climate.temperatureDay
? Math.abs(env.climate.temperatureDay - profile.optimalConditions.temperature.optimal)
: 5;
const humDiff = env.climate.avgHumidity
? Math.abs(env.climate.avgHumidity - profile.optimalConditions.humidity.optimal)
const humDiff = env.climate.humidityAverage
? Math.abs(env.climate.humidityAverage - profile.optimalConditions.humidity.optimal)
: 10;
climateScore = Math.max(0, 100 - tempDiff * 5 - humDiff * 1);
@ -388,7 +389,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (tempDiff > 3) {
improvements.push({
category: 'climate',
currentState: `${env.climate.avgTemperature?.toFixed(1) || 'unknown'}°C`,
currentState: `${env.climate.temperatureDay?.toFixed(1) || 'unknown'}°C`,
recommendedState: `${profile.optimalConditions.temperature.optimal}°C`,
priority: tempDiff > 6 ? 'high' : 'medium',
expectedImpact: 'Reduced stress and improved growth',
@ -408,7 +409,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Nutrients analysis
if (env.nutrients) {
nutrientsScore = 75; // Base score if nutrient data exists
if (env.nutrients.fertilizer?.schedule === 'regular') {
// Bonus for complete NPK profile
if (env.nutrients.nitrogen && env.nutrients.phosphorus && env.nutrients.potassium) {
nutrientsScore = 90;
}
}
@ -462,7 +464,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Find common soil types
const soilTypes = plantsWithEnv
.map(p => p.plant.environment?.soil?.soilType)
.map(p => p.plant.environment?.soil?.type)
.filter(Boolean);
const commonSoilType = this.findMostCommon(soilTypes as string[]);
@ -471,7 +473,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
patterns.push({
patternId: `pattern-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
species,
conditions: { soil: { soilType: commonSoilType } } as any,
conditions: { soil: { type: commonSoilType } } as any,
successMetric: 'health',
successValue: 85,
sampleSize: plantsWithEnv.length,
@ -527,7 +529,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (cached) return cached;
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const chain = blockchain.chain;
const block1 = chain.find(b => b.plant.id === plant1Id);
const block2 = chain.find(b => b.plant.id === plant2Id);
@ -545,14 +547,14 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Compare soil
if (env1?.soil && env2?.soil) {
totalFactors++;
if (env1.soil.soilType === env2.soil.soilType) {
if (env1.soil.type === env2.soil.type) {
matchingFactors.push('Soil type');
matchScore++;
} else {
differingFactors.push({
factor: 'Soil type',
plant1Value: env1.soil.soilType,
plant2Value: env2.soil.soilType
plant1Value: env1.soil.type,
plant2Value: env2.soil.type
});
}
@ -588,7 +590,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (env1?.climate && env2?.climate) {
totalFactors++;
const tempDiff = Math.abs(
(env1.climate.avgTemperature || 0) - (env2.climate.avgTemperature || 0)
(env1.climate.temperatureDay || 0) - (env2.climate.temperatureDay || 0)
);
if (tempDiff < 3) {
matchingFactors.push('Temperature');
@ -596,8 +598,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
} else {
differingFactors.push({
factor: 'Temperature',
plant1Value: env1.climate.avgTemperature,
plant2Value: env2.climate.avgTemperature
plant1Value: env1.climate.temperatureDay,
plant2Value: env2.climate.temperatureDay
});
}
}

View file

@ -178,7 +178,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
*/
private updateGrowerProfiles(): void {
const blockchain = getBlockchain();
const chain = blockchain.getChain().slice(1);
const chain = blockchain.chain.slice(1);
const ownerPlants = new Map<string, typeof chain>();
@ -219,7 +219,11 @@ export class GrowerAdvisoryAgent extends BaseAgent {
if (['growing', 'mature', 'flowering', 'fruiting'].includes(plant.plant.status)) {
existing.healthy++;
}
existing.yield += plant.plant.growthMetrics?.estimatedYieldKg || 2;
// Estimate yield based on health score, or use default of 2kg
const healthMultiplier = plant.plant.growthMetrics?.healthScore
? plant.plant.growthMetrics.healthScore / 50
: 1;
existing.yield += 2 * healthMultiplier;
historyMap.set(crop, existing);
}

View file

@ -102,7 +102,7 @@ export class NetworkDiscoveryAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const chain = blockchain.chain;
const plants = chain.slice(1);
// Build network from plant data

View file

@ -58,7 +58,7 @@ export class PlantLineageAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const chain = blockchain.chain;
// Skip genesis block
const plantBlocks = chain.slice(1);
@ -133,7 +133,7 @@ export class PlantLineageAgent extends BaseAgent {
totalLineageSize: ancestors.length + descendants.length + 1,
propagationChain,
geographicSpread,
oldestAncestorDate: oldestAncestor?.timestamp || plant.dateAcquired,
oldestAncestorDate: oldestAncestor?.timestamp || plant.registeredAt,
healthScore: this.calculateHealthScore(plant, chain)
};
}

View file

@ -232,7 +232,7 @@ export class SustainabilityAgent extends BaseAgent {
*/
private calculateWaterMetrics(): WaterMetrics {
const blockchain = getBlockchain();
const plantCount = blockchain.getChain().length - 1;
const plantCount = blockchain.chain.length - 1;
// Simulate water usage based on plant count
// Vertical farms use ~10% of traditional water
@ -265,7 +265,7 @@ export class SustainabilityAgent extends BaseAgent {
*/
private calculateWasteMetrics(): WasteMetrics {
const blockchain = getBlockchain();
const plants = blockchain.getChain().slice(1);
const plants = blockchain.chain.slice(1);
const deceasedPlants = plants.filter(p => p.plant.status === 'deceased').length;
const totalPlants = plants.length;
@ -311,7 +311,7 @@ export class SustainabilityAgent extends BaseAgent {
// Biodiversity: based on plant variety
const blockchain = getBlockchain();
const plants = blockchain.getChain().slice(1);
const plants = blockchain.chain.slice(1);
const uniqueSpecies = new Set(plants.map(p => p.plant.commonName)).size;
const biodiversity = Math.min(100, 30 + uniqueSpecies * 5);

View file

@ -11,9 +11,9 @@ export class PlantChain {
private plantIndex: Map<string, PlantBlock>; // Quick lookup by plant ID
constructor(difficulty: number = 4) {
this.chain = [this.createGenesisBlock()];
this.difficulty = difficulty;
this.plantIndex = new Map();
this.chain = [this.createGenesisBlock()];
}
/**

View file

@ -2,6 +2,15 @@
import { GrowingEnvironment, GrowthMetrics } from '../environment/types';
// Re-export types from environment
export type { GrowingEnvironment, GrowthMetrics };
// Re-export PlantBlock class
export { PlantBlock } from './PlantBlock';
// Propagation type alias
export type PropagationType = 'seed' | 'clone' | 'cutting' | 'division' | 'grafting' | 'original';
export interface PlantLocation {
latitude: number;
longitude: number;

View file

@ -154,7 +154,7 @@ export interface PlantingRecommendation {
// Quantities
recommendedQuantity: number;
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield';
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield' | 'sqm';
expectedYieldKg: number;
yieldConfidence: number; // 0-100

View file

@ -24,7 +24,7 @@ export interface FuzzyLocation {
*/
export function generateAnonymousId(): string {
const randomBytes = crypto.randomBytes(32);
return 'anon_' + crypto.createHash('sha256').update(randomBytes).digest('hex').substring(0, 16);
return 'anon_' + crypto.createHash('sha256').update(new Uint8Array(randomBytes)).digest('hex').substring(0, 16);
}
/**
@ -111,14 +111,14 @@ export function generateAnonymousPlantName(plantType: string, generation: number
*/
export function encryptData(data: string, key: string): string {
const algorithm = 'aes-256-cbc';
const keyHash = crypto.createHash('sha256').update(key).digest();
const iv = crypto.randomBytes(16);
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
const iv = new Uint8Array(crypto.randomBytes(16));
const cipher = crypto.createCipheriv(algorithm, keyHash, iv);
const cipher = crypto.createCipheriv(algorithm, keyHash as any, iv as any);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
return Buffer.from(iv).toString('hex') + ':' + encrypted;
}
/**
@ -126,13 +126,13 @@ export function encryptData(data: string, key: string): string {
*/
export function decryptData(encryptedData: string, key: string): string {
const algorithm = 'aes-256-cbc';
const keyHash = crypto.createHash('sha256').update(key).digest();
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
const parts = encryptedData.split(':');
const iv = Buffer.from(parts[0], 'hex');
const iv = new Uint8Array(Buffer.from(parts[0], 'hex'));
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(algorithm, keyHash, iv);
const decipher = crypto.createDecipheriv(algorithm, keyHash as any, iv as any);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');

View file

@ -151,7 +151,7 @@ module.exports = withPWA({
defaultLocale: "en",
},
images: {
domains: process.env.NEXT_IMAGE_DOMAIN ? [process.env.NEXT_IMAGE_DOMAIN] : [],
domains: [process.env.NEXT_IMAGE_DOMAIN].filter(Boolean),
},
async rewrites() {
return [

1030
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -85,8 +85,8 @@
"prisma": "^5.7.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "^3.0.15",
"ts-jest": "^29.1.0",
"typescript": "^4.5.5"
"ts-jest": "^29.4.5",
"typescript": "^5.9.3"
},
"lint-staged": {
"*.{ts,tsx}": [

View file

@ -212,7 +212,7 @@ export default function ZoneManagement() {
Start New Batch
</button>
)}
{(selectedZone.status === 'growing' || selectedZone.status === 'ready') && (
{(selectedZone.status === 'planted' || selectedZone.status === 'harvesting') && (
<button className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
Harvest
</button>

View file

@ -14,6 +14,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"downlevelIteration": true,
"baseUrl": "./",
"paths": {
"@/*": ["./*"],