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>
This commit is contained in:
@@ -28,4 +28,7 @@ STRIPE_SECRET_KEY=your_stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
|
||||
|
||||
# OpenAI Configuration (optional - for AI features)
|
||||
# If not set, AI features will return mock data
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OPENAI_MODEL=gpt-3.5-turbo
|
||||
# Available models: gpt-3.5-turbo, gpt-4, gpt-4-turbo-preview
|
||||
|
||||
265
backend/AI_SERVICE.md
Normal file
265
backend/AI_SERVICE.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# AI Service Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The AI Service provides intelligent task suggestions for street maintenance using OpenAI's GPT models. It analyzes street conditions, historical data, weather patterns, and community priorities to generate contextually relevant and actionable maintenance tasks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Context-Aware Suggestions**: Generates tasks based on street name, location, and past maintenance history
|
||||
- **Weather Integration**: Considers current weather conditions for appropriate task suggestions
|
||||
- **Community Priorities**: Aligns suggestions with community goals and priorities
|
||||
- **Fallback Mode**: Automatically falls back to high-quality mock suggestions if OpenAI API is unavailable
|
||||
- **Graceful Degradation**: Works without API key configuration, providing mock data with warnings
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# Required for AI features
|
||||
OPENAI_API_KEY=sk-your-api-key-here
|
||||
|
||||
# Optional - defaults to gpt-3.5-turbo
|
||||
OPENAI_MODEL=gpt-3.5-turbo
|
||||
```
|
||||
|
||||
**Available Models:**
|
||||
- `gpt-3.5-turbo` (default) - Fast, cost-effective, good for most use cases
|
||||
- `gpt-4` - More advanced reasoning, higher quality suggestions
|
||||
- `gpt-4-turbo-preview` - Latest GPT-4 with improved performance
|
||||
|
||||
### Cost Considerations
|
||||
|
||||
- **gpt-3.5-turbo**: ~$0.001-0.002 per request (typical task suggestion)
|
||||
- **gpt-4**: ~$0.03-0.06 per request
|
||||
- Each task suggestion request typically uses 500-1000 tokens
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Check AI Service Status
|
||||
|
||||
**GET** `/api/ai/status`
|
||||
|
||||
Returns the current status of the AI service.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-auth-token: <jwt-token>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"configured": true,
|
||||
"model": "gpt-3.5-turbo",
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Task Suggestions
|
||||
|
||||
**GET** `/api/ai/task-suggestions`
|
||||
|
||||
Generate AI-powered task suggestions for street maintenance.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
x-auth-token: <jwt-token>
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `streetId` (optional, string): ID of the specific street to get suggestions for
|
||||
- `count` (optional, integer): Number of suggestions to generate (1-10, default: 5)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"street": "Main Street",
|
||||
"description": "Remove litter from sidewalk",
|
||||
"details": "Improves neighborhood appearance and prevents storm drain blockage.",
|
||||
"estimatedTime": 30,
|
||||
"difficulty": "easy",
|
||||
"priority": "medium",
|
||||
"source": "ai"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"source": "ai",
|
||||
"count": 5,
|
||||
"aiConfigured": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (without API key):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [...],
|
||||
"meta": {
|
||||
"source": "mock",
|
||||
"count": 5,
|
||||
"aiConfigured": false
|
||||
},
|
||||
"warning": "OpenAI API not configured. Returning mock suggestions. Set OPENAI_API_KEY in environment variables for AI-powered suggestions."
|
||||
}
|
||||
```
|
||||
|
||||
## Service Usage
|
||||
|
||||
### Import the Service
|
||||
|
||||
```javascript
|
||||
const aiService = require('./services/aiService');
|
||||
```
|
||||
|
||||
### Generate Task Suggestions
|
||||
|
||||
```javascript
|
||||
const suggestions = await aiService.generateTaskSuggestions({
|
||||
streetName: "Main Street",
|
||||
location: {
|
||||
type: "Point",
|
||||
coordinates: [-122.4194, 37.7749]
|
||||
},
|
||||
pastTasks: [
|
||||
"Remove graffiti from wall",
|
||||
"Clear storm drains"
|
||||
],
|
||||
weather: {
|
||||
condition: "Partly cloudy",
|
||||
description: "Clear with mild temperatures",
|
||||
temperature: 72
|
||||
},
|
||||
communityPriorities: [
|
||||
"Safety improvements",
|
||||
"Environmental sustainability"
|
||||
],
|
||||
numberOfSuggestions: 5
|
||||
});
|
||||
```
|
||||
|
||||
### Check Service Availability
|
||||
|
||||
```javascript
|
||||
if (aiService.isAvailable()) {
|
||||
// Use AI-powered suggestions
|
||||
} else {
|
||||
// Use mock suggestions or alternative logic
|
||||
}
|
||||
```
|
||||
|
||||
### Get Service Status
|
||||
|
||||
```javascript
|
||||
const status = aiService.getStatus();
|
||||
// Returns: { configured: boolean, model: string, status: string }
|
||||
```
|
||||
|
||||
## Suggestion Structure
|
||||
|
||||
Each task suggestion includes:
|
||||
|
||||
- **street** (string): Name of the street
|
||||
- **description** (string): Brief, actionable task description (2-8 words)
|
||||
- **details** (string): Explanation of why the task is important (1 sentence)
|
||||
- **estimatedTime** (number): Estimated completion time in minutes
|
||||
- **difficulty** (string): Task difficulty - "easy", "moderate", or "hard"
|
||||
- **priority** (string): Task priority - "low", "medium", or "high"
|
||||
- **source** (string): Source of suggestion - "ai" or "mock"
|
||||
|
||||
## Prompt Engineering
|
||||
|
||||
The AI service uses carefully crafted prompts to generate high-quality suggestions:
|
||||
|
||||
1. **Context Building**: Includes street name, location, and relevant historical data
|
||||
2. **Past Tasks Analysis**: Avoids suggesting recently completed tasks
|
||||
3. **Weather Awareness**: Suggests weather-appropriate activities
|
||||
4. **Community Alignment**: Prioritizes tasks aligned with community goals
|
||||
5. **Safety Focus**: Emphasizes tasks safe for volunteers without specialized equipment
|
||||
6. **Structured Output**: Requests JSON format with specific fields
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service implements comprehensive error handling:
|
||||
|
||||
1. **API Failures**: Automatically falls back to mock suggestions
|
||||
2. **Timeout Protection**: 10-second timeout on API requests
|
||||
3. **Response Parsing**: Handles various response formats (including markdown code blocks)
|
||||
4. **Validation**: Ensures all suggestions have required fields
|
||||
5. **Logging**: Detailed error logging for debugging
|
||||
|
||||
## Testing
|
||||
|
||||
Run the AI service tests:
|
||||
|
||||
```bash
|
||||
npm test -- __tests__/routes/ai.test.js
|
||||
```
|
||||
|
||||
Test coverage includes:
|
||||
- Service status endpoint
|
||||
- Task suggestion generation (with and without street ID)
|
||||
- Input validation
|
||||
- Mock mode functionality
|
||||
- Error handling and fallbacks
|
||||
- Service integration tests
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Cache Suggestions**: Consider caching suggestions for frequently requested streets
|
||||
2. **Rate Limiting**: Implement rate limiting to control API costs
|
||||
3. **Batch Processing**: Generate suggestions for multiple streets in batches when possible
|
||||
4. **Monitor Usage**: Track OpenAI API usage and costs
|
||||
5. **Fallback Strategy**: Always have mock suggestions available as fallback
|
||||
6. **User Feedback**: Collect feedback on suggestion quality to improve prompts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "OpenAI API not configured" warning
|
||||
|
||||
**Solution**: Set `OPENAI_API_KEY` environment variable with your OpenAI API key.
|
||||
|
||||
### Issue: Suggestions are not contextual
|
||||
|
||||
**Solution**: Ensure you're passing street details, location, and past tasks to the service.
|
||||
|
||||
### Issue: API timeout errors
|
||||
|
||||
**Solution**: Check network connectivity to OpenAI API. The service will automatically fall back to mock suggestions.
|
||||
|
||||
### Issue: High API costs
|
||||
|
||||
**Solution**:
|
||||
- Use `gpt-3.5-turbo` instead of `gpt-4`
|
||||
- Implement caching for frequently requested streets
|
||||
- Add rate limiting to the endpoint
|
||||
- Reduce `numberOfSuggestions` parameter
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements to the AI service:
|
||||
|
||||
- [ ] Integration with real-time weather APIs
|
||||
- [ ] Machine learning on task completion rates
|
||||
- [ ] Seasonal task suggestions
|
||||
- [ ] User preference learning
|
||||
- [ ] Image analysis for street condition assessment
|
||||
- [ ] Multi-language support
|
||||
- [ ] Integration with city planning APIs
|
||||
- [ ] Automated task prioritization based on urgency
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check logs at `backend/server.log`
|
||||
- Review error messages in the response
|
||||
- Ensure environment variables are correctly set
|
||||
- Verify OpenAI API key is valid and has sufficient credits
|
||||
255
backend/__tests__/routes/ai.test.js
Normal file
255
backend/__tests__/routes/ai.test.js
Normal file
@@ -0,0 +1,255 @@
|
||||
const request = require("supertest");
|
||||
const express = require("express");
|
||||
|
||||
// Mock CouchDB service before importing routes
|
||||
jest.mock("../../services/couchdbService", () => ({
|
||||
initialize: jest.fn().mockResolvedValue(true),
|
||||
create: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
find: jest.fn(),
|
||||
createDocument: jest.fn(),
|
||||
updateDocument: jest.fn(),
|
||||
deleteDocument: jest.fn(),
|
||||
getDocument: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock logger to avoid console output during tests
|
||||
jest.mock("../../utils/logger", () => ({
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock("../../middleware/auth", () => (req, res, next) => {
|
||||
req.user = { id: "test-user-id" };
|
||||
next();
|
||||
});
|
||||
|
||||
const aiRoute = require("../../routes/ai");
|
||||
const aiService = require("../../services/aiService");
|
||||
const Street = require("../../models/Street");
|
||||
const Task = require("../../models/Task");
|
||||
const couchdbService = require("../../services/couchdbService");
|
||||
|
||||
// Setup Express app for testing
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/ai", aiRoute);
|
||||
|
||||
describe("AI Routes", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/ai/status", () => {
|
||||
it("should return AI service status", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/status")
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveProperty("configured");
|
||||
expect(response.body.data).toHaveProperty("status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/ai/task-suggestions", () => {
|
||||
it("should return task suggestions without streetId", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions")
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
expect(response.body.meta).toHaveProperty("source");
|
||||
expect(response.body.meta).toHaveProperty("aiConfigured");
|
||||
|
||||
// Check suggestion structure
|
||||
const suggestion = response.body.data[0];
|
||||
expect(suggestion).toHaveProperty("description");
|
||||
expect(suggestion).toHaveProperty("details");
|
||||
expect(suggestion).toHaveProperty("estimatedTime");
|
||||
expect(suggestion).toHaveProperty("difficulty");
|
||||
expect(suggestion).toHaveProperty("priority");
|
||||
expect(suggestion).toHaveProperty("source");
|
||||
});
|
||||
|
||||
it("should return task suggestions with custom count", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions?count=3")
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should validate count parameter maximum", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions?count=15")
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.errors).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("should validate count is a positive number", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions?count=0")
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.errors).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("should return task suggestions for specific street", async () => {
|
||||
const streetId = "test-street-123";
|
||||
const streetData = {
|
||||
_id: streetId,
|
||||
_rev: "1-abc",
|
||||
type: "street",
|
||||
name: "Test Avenue",
|
||||
location: {
|
||||
type: "Point",
|
||||
coordinates: [-122.4194, 37.7749]
|
||||
},
|
||||
status: "available",
|
||||
stats: {
|
||||
completedTasksCount: 0,
|
||||
reportsCount: 0,
|
||||
openReportsCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Mock street lookup
|
||||
couchdbService.getDocument.mockResolvedValueOnce(streetData);
|
||||
|
||||
// Mock task lookup
|
||||
couchdbService.find.mockResolvedValueOnce([
|
||||
{
|
||||
_id: "task-1",
|
||||
type: "task",
|
||||
street: { streetId, name: "Test Avenue" },
|
||||
description: "Remove graffiti from wall",
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
_id: "task-2",
|
||||
type: "task",
|
||||
street: { streetId, name: "Test Avenue" },
|
||||
description: "Clear storm drains",
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString()
|
||||
}
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/ai/task-suggestions?streetId=${streetId}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that street name is included
|
||||
const suggestion = response.body.data[0];
|
||||
expect(suggestion.street).toBe("Test Avenue");
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent street", async () => {
|
||||
// Mock street not found
|
||||
couchdbService.getDocument.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions?streetId=nonexistent-id")
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.msg).toBe("Street not found");
|
||||
});
|
||||
|
||||
it("should include warning when OpenAI is not configured", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/ai/task-suggestions")
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
if (!aiService.isAvailable()) {
|
||||
expect(response.body.warning).toBeDefined();
|
||||
expect(response.body.warning).toContain("OpenAI API not configured");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Service Integration", () => {
|
||||
it("should generate suggestions with proper structure", async () => {
|
||||
const suggestions = await aiService.generateTaskSuggestions({
|
||||
streetName: "Main Street",
|
||||
numberOfSuggestions: 5
|
||||
});
|
||||
|
||||
expect(suggestions).toBeInstanceOf(Array);
|
||||
expect(suggestions.length).toBe(5);
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
expect(suggestion).toHaveProperty("street");
|
||||
expect(suggestion).toHaveProperty("description");
|
||||
expect(suggestion).toHaveProperty("details");
|
||||
expect(suggestion).toHaveProperty("estimatedTime");
|
||||
expect(suggestion).toHaveProperty("difficulty");
|
||||
expect(suggestion).toHaveProperty("priority");
|
||||
expect(suggestion).toHaveProperty("source");
|
||||
|
||||
expect(["easy", "moderate", "hard"]).toContain(suggestion.difficulty);
|
||||
expect(["low", "medium", "high"]).toContain(suggestion.priority);
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect numberOfSuggestions parameter", async () => {
|
||||
const suggestions1 = await aiService.generateTaskSuggestions({
|
||||
streetName: "Oak Avenue",
|
||||
numberOfSuggestions: 3
|
||||
});
|
||||
|
||||
const suggestions2 = await aiService.generateTaskSuggestions({
|
||||
streetName: "Elm Street",
|
||||
numberOfSuggestions: 7
|
||||
});
|
||||
|
||||
expect(suggestions1.length).toBe(3);
|
||||
expect(suggestions2.length).toBe(7);
|
||||
});
|
||||
|
||||
it("should include street name in suggestions", async () => {
|
||||
const streetName = "Custom Street Name";
|
||||
const suggestions = await aiService.generateTaskSuggestions({
|
||||
streetName,
|
||||
numberOfSuggestions: 3
|
||||
});
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
expect(suggestion.street).toBe(streetName);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return mock data when OpenAI is not configured", async () => {
|
||||
const suggestions = await aiService.generateTaskSuggestions({
|
||||
streetName: "Test Street",
|
||||
numberOfSuggestions: 5
|
||||
});
|
||||
|
||||
expect(suggestions).toBeInstanceOf(Array);
|
||||
expect(suggestions.length).toBe(5);
|
||||
|
||||
// Mock suggestions should have source: "mock"
|
||||
if (!aiService.isAvailable()) {
|
||||
expect(suggestions[0].source).toBe("mock");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
35
backend/middleware/validators/aiValidator.js
Normal file
35
backend/middleware/validators/aiValidator.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { query, validationResult } = require("express-validator");
|
||||
|
||||
const validate = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
errors: errors.array().map((err) => ({
|
||||
field: err.path,
|
||||
message: err.msg,
|
||||
})),
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Task suggestions validation
|
||||
*/
|
||||
const taskSuggestionsValidation = [
|
||||
query("streetId")
|
||||
.optional()
|
||||
.isString()
|
||||
.withMessage("Street ID must be a string"),
|
||||
query("count")
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 10 })
|
||||
.withMessage("Count must be between 1 and 10"),
|
||||
validate,
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
taskSuggestionsValidation,
|
||||
validate,
|
||||
};
|
||||
@@ -1,22 +1,129 @@
|
||||
const express = require("express");
|
||||
const auth = require("../middleware/auth");
|
||||
const aiService = require("../services/aiService");
|
||||
const logger = require("../utils/logger");
|
||||
const { taskSuggestionsValidation } = require("../middleware/validators/aiValidator");
|
||||
const Street = require("../models/Street");
|
||||
const Task = require("../models/Task");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get AI task suggestions
|
||||
router.get("/task-suggestions", auth, async (req, res) => {
|
||||
// Get AI service status
|
||||
router.get("/status", auth, async (req, res) => {
|
||||
try {
|
||||
// In a real application, you would use a more sophisticated AI model to generate task suggestions.
|
||||
// For this example, we'll just return some mock data.
|
||||
const suggestions = [
|
||||
{ street: "Main St", description: "Clean up litter" },
|
||||
{ street: "Elm St", description: "Remove graffiti" },
|
||||
{ street: "Oak Ave", description: "Mow the lawn" },
|
||||
];
|
||||
res.json(suggestions);
|
||||
const status = aiService.getStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data: status
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
res.status(500).send("Server error");
|
||||
logger.error("Error getting AI service status", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get AI task suggestions
|
||||
router.get("/task-suggestions", auth, taskSuggestionsValidation, async (req, res) => {
|
||||
try {
|
||||
const { streetId, count = 5 } = req.query;
|
||||
const numberOfSuggestions = parseInt(count, 10);
|
||||
|
||||
let streetName = "General Street";
|
||||
let location = null;
|
||||
let pastTasks = [];
|
||||
|
||||
// If streetId is provided, fetch street details and past tasks
|
||||
if (streetId) {
|
||||
const street = await Street.findById(streetId);
|
||||
|
||||
if (!street) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
msg: "Street not found"
|
||||
});
|
||||
}
|
||||
|
||||
streetName = street.name;
|
||||
location = street.location;
|
||||
|
||||
// Fetch past completed tasks for this street
|
||||
const tasks = await Task.find({
|
||||
"street.streetId": streetId,
|
||||
status: "completed"
|
||||
});
|
||||
|
||||
// Extract task descriptions from past 30 days
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
pastTasks = tasks
|
||||
.filter(task => new Date(task.completedAt) > thirtyDaysAgo)
|
||||
.map(task => task.description)
|
||||
.slice(0, 10); // Limit to 10 most recent tasks
|
||||
|
||||
logger.info("Fetching AI suggestions for specific street", {
|
||||
streetId,
|
||||
streetName,
|
||||
pastTasksCount: pastTasks.length
|
||||
});
|
||||
} else {
|
||||
logger.info("Fetching general AI suggestions");
|
||||
}
|
||||
|
||||
// Mock weather data (in production, this could call a weather API)
|
||||
const weather = {
|
||||
condition: "Partly cloudy",
|
||||
description: "Clear with mild temperatures",
|
||||
temperature: 72
|
||||
};
|
||||
|
||||
// Example community priorities (in production, these could come from database)
|
||||
const communityPriorities = [
|
||||
"Safety improvements",
|
||||
"Environmental sustainability",
|
||||
"Neighborhood beautification"
|
||||
];
|
||||
|
||||
// Generate suggestions using AI service
|
||||
const suggestions = await aiService.generateTaskSuggestions({
|
||||
streetName,
|
||||
location,
|
||||
pastTasks,
|
||||
weather,
|
||||
communityPriorities,
|
||||
numberOfSuggestions
|
||||
});
|
||||
|
||||
// Add warning if using mock data
|
||||
const response = {
|
||||
success: true,
|
||||
data: suggestions,
|
||||
meta: {
|
||||
source: suggestions[0]?.source || "unknown",
|
||||
count: suggestions.length,
|
||||
aiConfigured: aiService.isAvailable()
|
||||
}
|
||||
};
|
||||
|
||||
if (!aiService.isAvailable()) {
|
||||
response.warning = "OpenAI API not configured. Returning mock suggestions. Set OPENAI_API_KEY in environment variables for AI-powered suggestions.";
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
|
||||
} catch (err) {
|
||||
logger.error("Error generating task suggestions", err, {
|
||||
streetId: req.query.streetId,
|
||||
userId: req.user.id
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
msg: "Server error while generating task suggestions"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
328
backend/services/aiService.js
Normal file
328
backend/services/aiService.js
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user