Files
adopt-a-street/backend/services/aiService.js
William Valentin 771d39a52b feat: integrate OpenAI API for intelligent task suggestions
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>
2025-11-03 13:19:00 -08:00

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();