Agents created: 1. PlantLineageAgent - Monitors plant ancestry and lineage integrity 2. TransportTrackerAgent - Tracks transport events and carbon footprint 3. DemandForecastAgent - Predicts consumer demand and market trends 4. VerticalFarmAgent - Manages vertical farm operations and optimization 5. EnvironmentAnalysisAgent - Analyzes growing conditions and recommendations 6. MarketMatchingAgent - Connects grower supply with consumer demand 7. SustainabilityAgent - Monitors environmental impact and sustainability 8. NetworkDiscoveryAgent - Maps geographic distribution and network analysis 9. QualityAssuranceAgent - Verifies blockchain integrity and data quality 10. GrowerAdvisoryAgent - Provides personalized growing recommendations Also includes: - BaseAgent abstract class for common functionality - AgentOrchestrator for centralized agent management - Comprehensive type definitions - Full documentation in docs/AGENTS.md
584 lines
18 KiB
TypeScript
584 lines
18 KiB
TypeScript
/**
|
|
* MarketMatchingAgent
|
|
* Connects grower supply with consumer demand
|
|
*
|
|
* Responsibilities:
|
|
* - Match supply commitments with demand signals
|
|
* - Optimize delivery routes and logistics
|
|
* - Facilitate fair pricing
|
|
* - Track match success rates
|
|
* - Enable local food distribution
|
|
*/
|
|
|
|
import { BaseAgent } from './BaseAgent';
|
|
import { AgentConfig, AgentTask } from './types';
|
|
import { getDemandForecaster } from '../demand/forecaster';
|
|
import { getTransportChain } from '../transport/tracker';
|
|
|
|
interface SupplyOffer {
|
|
id: string;
|
|
growerId: string;
|
|
growerName: string;
|
|
produceType: string;
|
|
availableKg: number;
|
|
pricePerKg: number;
|
|
location: { latitude: number; longitude: number };
|
|
availableFrom: string;
|
|
availableUntil: string;
|
|
certifications: string[];
|
|
deliveryRadius: number;
|
|
qualityGrade: 'premium' | 'standard' | 'economy';
|
|
}
|
|
|
|
interface DemandRequest {
|
|
id: string;
|
|
consumerId: string;
|
|
produceType: string;
|
|
requestedKg: number;
|
|
maxPricePerKg: number;
|
|
location: { latitude: number; longitude: number };
|
|
neededBy: string;
|
|
certificationRequirements: string[];
|
|
flexibleOnQuantity: boolean;
|
|
flexibleOnTiming: boolean;
|
|
}
|
|
|
|
interface MarketMatch {
|
|
id: string;
|
|
supplyId: string;
|
|
demandId: string;
|
|
growerId: string;
|
|
consumerId: string;
|
|
produceType: string;
|
|
matchedQuantityKg: number;
|
|
agreedPricePerKg: number;
|
|
deliveryDistanceKm: number;
|
|
estimatedCarbonKg: number;
|
|
matchScore: number;
|
|
status: 'proposed' | 'accepted' | 'rejected' | 'fulfilled' | 'cancelled';
|
|
createdAt: string;
|
|
deliveryDate?: string;
|
|
matchFactors: {
|
|
priceScore: number;
|
|
distanceScore: number;
|
|
certificationScore: number;
|
|
timingScore: number;
|
|
};
|
|
}
|
|
|
|
interface MarketStats {
|
|
totalMatches: number;
|
|
successfulMatches: number;
|
|
totalVolumeKg: number;
|
|
totalRevenue: number;
|
|
avgDistanceKm: number;
|
|
avgCarbonSavedKg: number;
|
|
matchSuccessRate: number;
|
|
topProduceTypes: { type: string; volumeKg: number }[];
|
|
}
|
|
|
|
interface PricingAnalysis {
|
|
produceType: string;
|
|
avgPrice: number;
|
|
minPrice: number;
|
|
maxPrice: number;
|
|
priceRange: 'stable' | 'moderate' | 'volatile';
|
|
recommendedPrice: number;
|
|
demandPressure: 'low' | 'medium' | 'high';
|
|
}
|
|
|
|
export class MarketMatchingAgent extends BaseAgent {
|
|
private supplyOffers: Map<string, SupplyOffer> = new Map();
|
|
private demandRequests: Map<string, DemandRequest> = new Map();
|
|
private matches: Map<string, MarketMatch> = new Map();
|
|
private pricingData: Map<string, PricingAnalysis> = new Map();
|
|
private marketStats: MarketStats;
|
|
|
|
constructor() {
|
|
const config: AgentConfig = {
|
|
id: 'market-matching-agent',
|
|
name: 'Market Matching Agent',
|
|
description: 'Connects supply with demand for local food distribution',
|
|
enabled: true,
|
|
intervalMs: 60000, // Run every minute
|
|
priority: 'high',
|
|
maxRetries: 3,
|
|
timeoutMs: 30000
|
|
};
|
|
super(config);
|
|
|
|
this.marketStats = {
|
|
totalMatches: 0,
|
|
successfulMatches: 0,
|
|
totalVolumeKg: 0,
|
|
totalRevenue: 0,
|
|
avgDistanceKm: 0,
|
|
avgCarbonSavedKg: 0,
|
|
matchSuccessRate: 0,
|
|
topProduceTypes: []
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Main execution cycle
|
|
*/
|
|
async runOnce(): Promise<AgentTask | null> {
|
|
// Clean up expired offers and requests
|
|
this.cleanupExpired();
|
|
|
|
// Find potential matches
|
|
const newMatches = this.findMatches();
|
|
|
|
// Update pricing analysis
|
|
this.updatePricingAnalysis();
|
|
|
|
// Update market statistics
|
|
this.updateMarketStats();
|
|
|
|
// Generate alerts for unmatched supply/demand
|
|
this.checkUnmatchedAlerts();
|
|
|
|
return this.createTaskResult('market_matching', 'completed', {
|
|
activeSupplyOffers: this.supplyOffers.size,
|
|
activeDemandRequests: this.demandRequests.size,
|
|
newMatchesFound: newMatches.length,
|
|
totalActiveMatches: this.matches.size,
|
|
marketStats: this.marketStats
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register a supply offer
|
|
*/
|
|
registerSupplyOffer(offer: SupplyOffer): void {
|
|
this.supplyOffers.set(offer.id, offer);
|
|
}
|
|
|
|
/**
|
|
* Register a demand request
|
|
*/
|
|
registerDemandRequest(request: DemandRequest): void {
|
|
this.demandRequests.set(request.id, request);
|
|
}
|
|
|
|
/**
|
|
* Find potential matches between supply and demand
|
|
*/
|
|
private findMatches(): MarketMatch[] {
|
|
const newMatches: MarketMatch[] = [];
|
|
|
|
for (const [supplyId, supply] of this.supplyOffers) {
|
|
// Check if supply already has full matches
|
|
const existingMatches = Array.from(this.matches.values())
|
|
.filter(m => m.supplyId === supplyId && m.status !== 'rejected' && m.status !== 'cancelled');
|
|
|
|
const matchedQuantity = existingMatches.reduce((sum, m) => sum + m.matchedQuantityKg, 0);
|
|
const remainingSupply = supply.availableKg - matchedQuantity;
|
|
|
|
if (remainingSupply <= 0) continue;
|
|
|
|
// Find matching demand requests
|
|
for (const [demandId, demand] of this.demandRequests) {
|
|
// Check if demand already matched
|
|
const demandMatches = Array.from(this.matches.values())
|
|
.filter(m => m.demandId === demandId && m.status !== 'rejected' && m.status !== 'cancelled');
|
|
|
|
if (demandMatches.length > 0) continue;
|
|
|
|
// Check produce type match
|
|
if (supply.produceType.toLowerCase() !== demand.produceType.toLowerCase()) continue;
|
|
|
|
// Check price compatibility
|
|
if (supply.pricePerKg > demand.maxPricePerKg) continue;
|
|
|
|
// Check delivery radius
|
|
const distance = this.calculateDistance(supply.location, demand.location);
|
|
if (distance > supply.deliveryRadius) continue;
|
|
|
|
// Check timing
|
|
const supplyAvailable = new Date(supply.availableFrom);
|
|
const demandNeeded = new Date(demand.neededBy);
|
|
if (supplyAvailable > demandNeeded) continue;
|
|
|
|
// Check certifications
|
|
const certsMet = demand.certificationRequirements.every(
|
|
cert => supply.certifications.includes(cert)
|
|
);
|
|
if (!certsMet) continue;
|
|
|
|
// Calculate match score
|
|
const matchScore = this.calculateMatchScore(supply, demand, distance);
|
|
|
|
// Calculate matched quantity
|
|
const matchedQty = Math.min(remainingSupply, demand.requestedKg);
|
|
|
|
// Estimate carbon footprint
|
|
const carbonKg = this.estimateCarbonFootprint(distance, matchedQty);
|
|
|
|
// Create match
|
|
const match: MarketMatch = {
|
|
id: `match-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
supplyId,
|
|
demandId,
|
|
growerId: supply.growerId,
|
|
consumerId: demand.consumerId,
|
|
produceType: supply.produceType,
|
|
matchedQuantityKg: matchedQty,
|
|
agreedPricePerKg: this.calculateFairPrice(supply.pricePerKg, demand.maxPricePerKg),
|
|
deliveryDistanceKm: Math.round(distance * 100) / 100,
|
|
estimatedCarbonKg: carbonKg,
|
|
matchScore,
|
|
status: 'proposed',
|
|
createdAt: new Date().toISOString(),
|
|
matchFactors: {
|
|
priceScore: this.calculatePriceScore(supply.pricePerKg, demand.maxPricePerKg),
|
|
distanceScore: this.calculateDistanceScore(distance),
|
|
certificationScore: certsMet ? 100 : 0,
|
|
timingScore: this.calculateTimingScore(supplyAvailable, demandNeeded)
|
|
}
|
|
};
|
|
|
|
this.matches.set(match.id, match);
|
|
newMatches.push(match);
|
|
|
|
// Alert for high-value matches
|
|
if (matchScore >= 90 && matchedQty >= 10) {
|
|
this.createAlert('info', 'High-Quality Match Found',
|
|
`${matchedQty}kg of ${supply.produceType} matched between grower and consumer`,
|
|
{ relatedEntityId: match.id, relatedEntityType: 'match' }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return newMatches;
|
|
}
|
|
|
|
/**
|
|
* Calculate match score (0-100)
|
|
*/
|
|
private calculateMatchScore(supply: SupplyOffer, demand: DemandRequest, distance: number): number {
|
|
let score = 0;
|
|
|
|
// Price score (30 points max)
|
|
const priceRatio = supply.pricePerKg / demand.maxPricePerKg;
|
|
score += Math.max(0, 30 * (1 - priceRatio));
|
|
|
|
// Distance score (25 points max) - shorter is better
|
|
score += Math.max(0, 25 * (1 - distance / 100));
|
|
|
|
// Quantity match score (20 points max)
|
|
const qtyRatio = Math.min(supply.availableKg, demand.requestedKg) /
|
|
Math.max(supply.availableKg, demand.requestedKg);
|
|
score += 20 * qtyRatio;
|
|
|
|
// Quality score (15 points max)
|
|
const qualityPoints: Record<string, number> = { premium: 15, standard: 10, economy: 5 };
|
|
score += qualityPoints[supply.qualityGrade] || 5;
|
|
|
|
// Certification match (10 points max)
|
|
if (supply.certifications.length > 0) {
|
|
const certMatch = demand.certificationRequirements.filter(
|
|
cert => supply.certifications.includes(cert)
|
|
).length / Math.max(1, demand.certificationRequirements.length);
|
|
score += 10 * certMatch;
|
|
} else if (demand.certificationRequirements.length === 0) {
|
|
score += 10;
|
|
}
|
|
|
|
return Math.round(Math.min(100, score));
|
|
}
|
|
|
|
/**
|
|
* Calculate fair price between supply and demand
|
|
*/
|
|
private calculateFairPrice(supplyPrice: number, maxDemandPrice: number): number {
|
|
// Weighted average favoring supply price slightly
|
|
return Math.round((supplyPrice * 0.6 + maxDemandPrice * 0.4) * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Calculate price score
|
|
*/
|
|
private calculatePriceScore(supplyPrice: number, maxDemandPrice: number): number {
|
|
if (supplyPrice >= maxDemandPrice) return 0;
|
|
return Math.round((1 - supplyPrice / maxDemandPrice) * 100);
|
|
}
|
|
|
|
/**
|
|
* Calculate distance score
|
|
*/
|
|
private calculateDistanceScore(distance: number): number {
|
|
// 100 points for 0km, 0 points for 100km+
|
|
return Math.max(0, Math.round(100 * (1 - distance / 100)));
|
|
}
|
|
|
|
/**
|
|
* Calculate timing score
|
|
*/
|
|
private calculateTimingScore(available: Date, needed: Date): number {
|
|
const daysUntilNeeded = (needed.getTime() - available.getTime()) / (24 * 60 * 60 * 1000);
|
|
if (daysUntilNeeded < 0) return 0;
|
|
if (daysUntilNeeded > 14) return 50;
|
|
return Math.round(100 * (1 - daysUntilNeeded / 14));
|
|
}
|
|
|
|
/**
|
|
* Estimate carbon footprint for delivery
|
|
*/
|
|
private estimateCarbonFootprint(distanceKm: number, weightKg: number): number {
|
|
// Assume local electric vehicle: 0.02 kg CO2 per km per kg
|
|
return Math.round(0.02 * distanceKm * weightKg * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Calculate Haversine distance
|
|
*/
|
|
private calculateDistance(
|
|
loc1: { latitude: number; longitude: number },
|
|
loc2: { latitude: number; longitude: number }
|
|
): number {
|
|
const R = 6371; // km
|
|
const dLat = (loc2.latitude - loc1.latitude) * Math.PI / 180;
|
|
const dLon = (loc2.longitude - loc1.longitude) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(loc1.latitude * Math.PI / 180) * Math.cos(loc2.latitude * Math.PI / 180) *
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired offers and requests
|
|
*/
|
|
private cleanupExpired(): void {
|
|
const now = new Date();
|
|
|
|
for (const [id, supply] of this.supplyOffers) {
|
|
if (new Date(supply.availableUntil) < now) {
|
|
this.supplyOffers.delete(id);
|
|
}
|
|
}
|
|
|
|
for (const [id, demand] of this.demandRequests) {
|
|
if (new Date(demand.neededBy) < now) {
|
|
this.demandRequests.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update pricing analysis
|
|
*/
|
|
private updatePricingAnalysis(): void {
|
|
const pricesByType = new Map<string, number[]>();
|
|
|
|
for (const supply of this.supplyOffers.values()) {
|
|
const prices = pricesByType.get(supply.produceType) || [];
|
|
prices.push(supply.pricePerKg);
|
|
pricesByType.set(supply.produceType, prices);
|
|
}
|
|
|
|
for (const [produceType, prices] of pricesByType) {
|
|
if (prices.length === 0) continue;
|
|
|
|
const avg = prices.reduce((a, b) => a + b, 0) / prices.length;
|
|
const min = Math.min(...prices);
|
|
const max = Math.max(...prices);
|
|
const range = max - min;
|
|
|
|
let priceRange: PricingAnalysis['priceRange'];
|
|
if (range / avg < 0.1) priceRange = 'stable';
|
|
else if (range / avg < 0.3) priceRange = 'moderate';
|
|
else priceRange = 'volatile';
|
|
|
|
// Count demand for this produce
|
|
const demandCount = Array.from(this.demandRequests.values())
|
|
.filter(d => d.produceType.toLowerCase() === produceType.toLowerCase())
|
|
.length;
|
|
|
|
let demandPressure: PricingAnalysis['demandPressure'];
|
|
if (demandCount > prices.length * 2) demandPressure = 'high';
|
|
else if (demandCount > prices.length) demandPressure = 'medium';
|
|
else demandPressure = 'low';
|
|
|
|
this.pricingData.set(produceType, {
|
|
produceType,
|
|
avgPrice: Math.round(avg * 100) / 100,
|
|
minPrice: Math.round(min * 100) / 100,
|
|
maxPrice: Math.round(max * 100) / 100,
|
|
priceRange,
|
|
recommendedPrice: Math.round((avg + (demandPressure === 'high' ? avg * 0.1 : 0)) * 100) / 100,
|
|
demandPressure
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update market statistics
|
|
*/
|
|
private updateMarketStats(): void {
|
|
const allMatches = Array.from(this.matches.values());
|
|
const successful = allMatches.filter(m => m.status === 'fulfilled');
|
|
|
|
const volumeByType = new Map<string, number>();
|
|
|
|
let totalDistance = 0;
|
|
let totalCarbon = 0;
|
|
let totalRevenue = 0;
|
|
|
|
for (const match of successful) {
|
|
volumeByType.set(
|
|
match.produceType,
|
|
(volumeByType.get(match.produceType) || 0) + match.matchedQuantityKg
|
|
);
|
|
|
|
totalDistance += match.deliveryDistanceKm;
|
|
totalCarbon += match.estimatedCarbonKg;
|
|
totalRevenue += match.matchedQuantityKg * match.agreedPricePerKg;
|
|
}
|
|
|
|
const topProduceTypes = Array.from(volumeByType.entries())
|
|
.map(([type, volumeKg]) => ({ type, volumeKg }))
|
|
.sort((a, b) => b.volumeKg - a.volumeKg)
|
|
.slice(0, 5);
|
|
|
|
this.marketStats = {
|
|
totalMatches: allMatches.length,
|
|
successfulMatches: successful.length,
|
|
totalVolumeKg: Math.round(successful.reduce((sum, m) => sum + m.matchedQuantityKg, 0) * 10) / 10,
|
|
totalRevenue: Math.round(totalRevenue * 100) / 100,
|
|
avgDistanceKm: successful.length > 0 ? Math.round(totalDistance / successful.length * 10) / 10 : 0,
|
|
avgCarbonSavedKg: successful.length > 0 ? Math.round(totalCarbon / successful.length * 100) / 100 : 0,
|
|
matchSuccessRate: allMatches.length > 0
|
|
? Math.round(successful.length / allMatches.length * 100)
|
|
: 0,
|
|
topProduceTypes
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check for unmatched supply/demand alerts
|
|
*/
|
|
private checkUnmatchedAlerts(): void {
|
|
// Alert for supply that's been available for > 3 days without matches
|
|
const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000;
|
|
|
|
for (const supply of this.supplyOffers.values()) {
|
|
const hasMatches = Array.from(this.matches.values())
|
|
.some(m => m.supplyId === supply.id);
|
|
|
|
if (!hasMatches && new Date(supply.availableFrom).getTime() < threeDaysAgo) {
|
|
this.createAlert('warning', 'Unmatched Supply',
|
|
`${supply.availableKg}kg of ${supply.produceType} from ${supply.growerName} has no matches`,
|
|
{
|
|
actionRequired: 'Consider adjusting price or expanding delivery radius',
|
|
relatedEntityId: supply.id,
|
|
relatedEntityType: 'supply'
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Alert for urgent demand without matches
|
|
const oneDayFromNow = Date.now() + 24 * 60 * 60 * 1000;
|
|
|
|
for (const demand of this.demandRequests.values()) {
|
|
const hasMatches = Array.from(this.matches.values())
|
|
.some(m => m.demandId === demand.id);
|
|
|
|
if (!hasMatches && new Date(demand.neededBy).getTime() < oneDayFromNow) {
|
|
this.createAlert('warning', 'Urgent Unmatched Demand',
|
|
`${demand.requestedKg}kg of ${demand.produceType} needed within 24 hours has no matches`,
|
|
{
|
|
actionRequired: 'Expand search radius or consider alternatives',
|
|
relatedEntityId: demand.id,
|
|
relatedEntityType: 'demand'
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accept a match
|
|
*/
|
|
acceptMatch(matchId: string): boolean {
|
|
const match = this.matches.get(matchId);
|
|
if (!match || match.status !== 'proposed') return false;
|
|
|
|
match.status = 'accepted';
|
|
match.deliveryDate = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Fulfill a match
|
|
*/
|
|
fulfillMatch(matchId: string): boolean {
|
|
const match = this.matches.get(matchId);
|
|
if (!match || match.status !== 'accepted') return false;
|
|
|
|
match.status = 'fulfilled';
|
|
this.marketStats.successfulMatches++;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get match by ID
|
|
*/
|
|
getMatch(matchId: string): MarketMatch | null {
|
|
return this.matches.get(matchId) || null;
|
|
}
|
|
|
|
/**
|
|
* Get all matches
|
|
*/
|
|
getAllMatches(): MarketMatch[] {
|
|
return Array.from(this.matches.values());
|
|
}
|
|
|
|
/**
|
|
* Get matches for a grower
|
|
*/
|
|
getGrowerMatches(growerId: string): MarketMatch[] {
|
|
return Array.from(this.matches.values())
|
|
.filter(m => m.growerId === growerId);
|
|
}
|
|
|
|
/**
|
|
* Get matches for a consumer
|
|
*/
|
|
getConsumerMatches(consumerId: string): MarketMatch[] {
|
|
return Array.from(this.matches.values())
|
|
.filter(m => m.consumerId === consumerId);
|
|
}
|
|
|
|
/**
|
|
* Get pricing analysis
|
|
*/
|
|
getPricingAnalysis(produceType?: string): PricingAnalysis[] {
|
|
if (produceType) {
|
|
const analysis = this.pricingData.get(produceType);
|
|
return analysis ? [analysis] : [];
|
|
}
|
|
return Array.from(this.pricingData.values());
|
|
}
|
|
|
|
/**
|
|
* Get market stats
|
|
*/
|
|
getMarketStats(): MarketStats {
|
|
return this.marketStats;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let marketAgentInstance: MarketMatchingAgent | null = null;
|
|
|
|
export function getMarketMatchingAgent(): MarketMatchingAgent {
|
|
if (!marketAgentInstance) {
|
|
marketAgentInstance = new MarketMatchingAgent();
|
|
}
|
|
return marketAgentInstance;
|
|
}
|