localgreenchain/lib/agents/MarketMatchingAgent.ts
Claude 4235e17f60
Add comprehensive 10-agent autonomous system for LocalGreenChain
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
2025-11-22 21:24:40 +00:00

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;
}