Implement real OpenAI integration for the AI task suggestions feature: - Create aiService.js with GPT-3.5/GPT-4 integration - Add context-aware prompt engineering (street data, past tasks, weather, priorities) - Implement automatic fallback to high-quality mock suggestions - Add graceful degradation when API key not configured - Create comprehensive error handling and timeout protection - Add input validation with aiValidator middleware - Implement /api/ai/status endpoint for service monitoring - Add 12 passing test cases covering all functionality - Document OpenAI model configuration in .env.example - Create detailed AI_SERVICE.md documentation Key features: - Uses axios to call OpenAI API directly (no SDK dependency) - Analyzes street conditions and past 30 days of completed tasks - Generates structured JSON responses with task metadata - 10-second timeout with automatic fallback - Comprehensive logging using centralized logger service - Returns warnings when running in mock mode All tests pass (12/12). Ready for production use with or without API key. 🤖 Generated with AI Assistant Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
329 lines
9.8 KiB
JavaScript
329 lines
9.8 KiB
JavaScript
/**
|
|
* AI Service for generating intelligent task suggestions
|
|
* Uses OpenAI API to analyze street conditions and suggest maintenance tasks
|
|
*/
|
|
|
|
const axios = require("axios");
|
|
const logger = require("../utils/logger");
|
|
|
|
class AIService {
|
|
constructor() {
|
|
this.apiKey = process.env.OPENAI_API_KEY;
|
|
this.model = process.env.OPENAI_MODEL || "gpt-3.5-turbo";
|
|
this.apiUrl = "https://api.openai.com/v1/chat/completions";
|
|
this.isConfigured = !!this.apiKey;
|
|
|
|
if (!this.isConfigured) {
|
|
logger.warn("OpenAI API key not configured. AI service will return mock data.");
|
|
} else {
|
|
logger.info("AI Service initialized", { model: this.model });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate task suggestions for a street
|
|
* @param {Object} options - Options for generating suggestions
|
|
* @param {string} options.streetName - Name of the street
|
|
* @param {Object} options.location - Location coordinates {type: 'Point', coordinates: [lng, lat]}
|
|
* @param {Array} options.pastTasks - Array of past task descriptions
|
|
* @param {Object} options.weather - Weather conditions (optional)
|
|
* @param {Array} options.communityPriorities - Community priorities (optional)
|
|
* @param {number} options.numberOfSuggestions - Number of suggestions to generate (default: 5)
|
|
* @returns {Promise<Array>} Array of task suggestions
|
|
*/
|
|
async generateTaskSuggestions(options = {}) {
|
|
const {
|
|
streetName,
|
|
location,
|
|
pastTasks = [],
|
|
weather = null,
|
|
communityPriorities = [],
|
|
numberOfSuggestions = 5
|
|
} = options;
|
|
|
|
// If API is not configured, return mock suggestions with warning
|
|
if (!this.isConfigured) {
|
|
logger.warn("Returning mock suggestions - OpenAI API not configured");
|
|
return this._getMockSuggestions(streetName, numberOfSuggestions);
|
|
}
|
|
|
|
try {
|
|
// Build context-aware prompt
|
|
const prompt = this._buildPrompt({
|
|
streetName,
|
|
location,
|
|
pastTasks,
|
|
weather,
|
|
communityPriorities,
|
|
numberOfSuggestions
|
|
});
|
|
|
|
logger.debug("Generating AI task suggestions", {
|
|
streetName,
|
|
model: this.model,
|
|
pastTasksCount: pastTasks.length
|
|
});
|
|
|
|
// Call OpenAI API
|
|
const response = await axios.post(
|
|
this.apiUrl,
|
|
{
|
|
model: this.model,
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: "You are a helpful assistant that generates practical street maintenance task suggestions for community volunteers. Focus on actionable, safe, and impactful tasks that can be completed by regular citizens."
|
|
},
|
|
{
|
|
role: "user",
|
|
content: prompt
|
|
}
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1000
|
|
},
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${this.apiKey}`
|
|
},
|
|
timeout: 10000 // 10 second timeout
|
|
}
|
|
);
|
|
|
|
// Parse the response
|
|
const suggestions = this._parseOpenAIResponse(response.data, streetName);
|
|
|
|
logger.info("AI task suggestions generated successfully", {
|
|
streetName,
|
|
count: suggestions.length
|
|
});
|
|
|
|
return suggestions;
|
|
|
|
} catch (error) {
|
|
logger.error("Error generating AI task suggestions", error, {
|
|
streetName,
|
|
errorType: error.response?.status || error.code
|
|
});
|
|
|
|
// Fallback to mock suggestions on error
|
|
logger.info("Falling back to mock suggestions due to API error");
|
|
return this._getMockSuggestions(streetName, numberOfSuggestions);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a context-aware prompt for OpenAI
|
|
* @private
|
|
*/
|
|
_buildPrompt(options) {
|
|
const {
|
|
streetName,
|
|
location,
|
|
pastTasks,
|
|
weather,
|
|
communityPriorities,
|
|
numberOfSuggestions
|
|
} = options;
|
|
|
|
let prompt = `Generate ${numberOfSuggestions} practical street maintenance task suggestions for "${streetName}".`;
|
|
|
|
// Add location context if available
|
|
if (location && location.coordinates) {
|
|
const [lng, lat] = location.coordinates;
|
|
prompt += `\nLocation: Latitude ${lat.toFixed(4)}, Longitude ${lng.toFixed(4)}`;
|
|
}
|
|
|
|
// Add past tasks context
|
|
if (pastTasks.length > 0) {
|
|
prompt += `\n\nRecent tasks completed on this street:`;
|
|
pastTasks.slice(0, 10).forEach((task, index) => {
|
|
prompt += `\n${index + 1}. ${task}`;
|
|
});
|
|
prompt += `\n\nAvoid suggesting tasks that are too similar to recently completed ones.`;
|
|
}
|
|
|
|
// Add weather context
|
|
if (weather) {
|
|
prompt += `\n\nCurrent weather conditions: ${weather.description || weather.condition}`;
|
|
if (weather.temperature) {
|
|
prompt += ` (${weather.temperature}°F)`;
|
|
}
|
|
prompt += `\nSuggest tasks appropriate for these weather conditions.`;
|
|
}
|
|
|
|
// Add community priorities
|
|
if (communityPriorities.length > 0) {
|
|
prompt += `\n\nCommunity priorities: ${communityPriorities.join(", ")}`;
|
|
prompt += `\nPrioritize suggestions that align with these community goals.`;
|
|
}
|
|
|
|
// Add formatting instructions
|
|
prompt += `\n\nFormat your response as a JSON array of objects, each with:
|
|
- "description": A clear, actionable task description (2-8 words)
|
|
- "details": A brief explanation of why this task is important (1 sentence)
|
|
- "estimatedTime": Estimated time to complete in minutes (number)
|
|
- "difficulty": Either "easy", "moderate", or "hard"
|
|
- "priority": Either "low", "medium", or "high"
|
|
|
|
Example format:
|
|
[
|
|
{
|
|
"description": "Remove litter from sidewalk",
|
|
"details": "Improves neighborhood appearance and prevents storm drain blockage.",
|
|
"estimatedTime": 30,
|
|
"difficulty": "easy",
|
|
"priority": "medium"
|
|
}
|
|
]
|
|
|
|
Ensure tasks are safe for volunteers without specialized equipment.`;
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/**
|
|
* Parse OpenAI API response into task suggestions
|
|
* @private
|
|
*/
|
|
_parseOpenAIResponse(data, streetName) {
|
|
try {
|
|
const content = data.choices[0]?.message?.content;
|
|
|
|
if (!content) {
|
|
throw new Error("No content in OpenAI response");
|
|
}
|
|
|
|
// Extract JSON from response (handle markdown code blocks)
|
|
let jsonStr = content.trim();
|
|
|
|
// Remove markdown code block syntax if present
|
|
if (jsonStr.startsWith("```")) {
|
|
jsonStr = jsonStr.replace(/```json?\n?/g, "").replace(/```\n?/g, "");
|
|
}
|
|
|
|
const suggestions = JSON.parse(jsonStr);
|
|
|
|
// Validate and format suggestions
|
|
if (!Array.isArray(suggestions)) {
|
|
throw new Error("Response is not an array");
|
|
}
|
|
|
|
return suggestions.map(suggestion => ({
|
|
street: streetName,
|
|
description: suggestion.description || "Maintenance task",
|
|
details: suggestion.details || "",
|
|
estimatedTime: suggestion.estimatedTime || 60,
|
|
difficulty: suggestion.difficulty || "moderate",
|
|
priority: suggestion.priority || "medium",
|
|
source: "ai"
|
|
}));
|
|
|
|
} catch (error) {
|
|
logger.error("Error parsing OpenAI response", error, {
|
|
response: data.choices[0]?.message?.content?.substring(0, 200)
|
|
});
|
|
throw new Error("Failed to parse AI response");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get mock task suggestions as fallback
|
|
* @private
|
|
*/
|
|
_getMockSuggestions(streetName, count = 5) {
|
|
const mockPool = [
|
|
{
|
|
description: "Remove litter from sidewalk",
|
|
details: "Improves neighborhood appearance and prevents storm drain blockage.",
|
|
estimatedTime: 30,
|
|
difficulty: "easy",
|
|
priority: "medium"
|
|
},
|
|
{
|
|
description: "Clear leaves from gutters",
|
|
details: "Prevents water accumulation and potential flooding during rain.",
|
|
estimatedTime: 45,
|
|
difficulty: "easy",
|
|
priority: "high"
|
|
},
|
|
{
|
|
description: "Trim overgrown vegetation",
|
|
details: "Maintains clear walkways and improves visibility for pedestrians.",
|
|
estimatedTime: 60,
|
|
difficulty: "moderate",
|
|
priority: "medium"
|
|
},
|
|
{
|
|
description: "Paint over graffiti",
|
|
details: "Restores aesthetic appeal and discourages vandalism.",
|
|
estimatedTime: 90,
|
|
difficulty: "moderate",
|
|
priority: "high"
|
|
},
|
|
{
|
|
description: "Report broken street lights",
|
|
details: "Enhances safety and security for residents during nighttime.",
|
|
estimatedTime: 15,
|
|
difficulty: "easy",
|
|
priority: "high"
|
|
},
|
|
{
|
|
description: "Clean storm drains",
|
|
details: "Ensures proper drainage and prevents flooding.",
|
|
estimatedTime: 40,
|
|
difficulty: "moderate",
|
|
priority: "medium"
|
|
},
|
|
{
|
|
description: "Repair minor sidewalk cracks",
|
|
details: "Prevents trip hazards and further deterioration.",
|
|
estimatedTime: 120,
|
|
difficulty: "hard",
|
|
priority: "low"
|
|
},
|
|
{
|
|
description: "Water street trees",
|
|
details: "Maintains urban canopy and provides shade for the community.",
|
|
estimatedTime: 25,
|
|
difficulty: "easy",
|
|
priority: "low"
|
|
}
|
|
];
|
|
|
|
// Randomly select suggestions from the pool
|
|
const shuffled = mockPool.sort(() => 0.5 - Math.random());
|
|
const selected = shuffled.slice(0, Math.min(count, mockPool.length));
|
|
|
|
return selected.map(suggestion => ({
|
|
street: streetName || "Selected Street",
|
|
...suggestion,
|
|
source: "mock"
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Check if AI service is properly configured
|
|
* @returns {boolean}
|
|
*/
|
|
isAvailable() {
|
|
return this.isConfigured;
|
|
}
|
|
|
|
/**
|
|
* Get service status information
|
|
* @returns {Object}
|
|
*/
|
|
getStatus() {
|
|
return {
|
|
configured: this.isConfigured,
|
|
model: this.isConfigured ? this.model : null,
|
|
status: this.isConfigured ? "active" : "mock_mode"
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
module.exports = new AIService();
|