diff --git a/apps/server/README.md b/apps/server/README.md index 2e1e5eb..b7588ef 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -122,10 +122,9 @@ interface CheckinResponse { success: true; data: { sessionId: string; // UUID for this session - transcript: string; // Transcribed text - confidence: number; // Transcription confidence (0-1) + transcript: string; // Transcribed text from audio sentiment: { - score: number; // Sentiment score (0-1) + score: number; // Sentiment score (0-1, lower = negative) label: 'positive' | 'negative' | 'neutral'; confidence: number; // Sentiment confidence (0-1) }; @@ -133,7 +132,7 @@ interface CheckinResponse { breathingExercise: { title: string; instructions: string[]; - duration: number; // Duration in minutes + duration: number; // Duration in seconds }; stretchExercise: { title: string; @@ -148,15 +147,9 @@ interface CheckinResponse { }>; motivationalMessage: string; }; - audioUrl: string; // URL to generated coaching audio - audioMetadata: { - duration: number; // Audio duration in seconds - fileSize: number; // File size in bytes - format: 'mp3' | 'wav'; - }; + audioUrl: string; // URL to generated coaching audio (mp3) }; processingTime: number; // Total processing time in ms - timestamp: string; // ISO timestamp } ``` @@ -186,60 +179,58 @@ curl -X POST http://localhost:4000/api/checkin \ { "success": true, "data": { - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "transcript": "I'm feeling really stressed about my upcoming exams and I can't seem to focus on studying.", - "confidence": 0.94, + "sessionId": "91edb2fc-ae1b-4dde-a4ca-76b8643dd3c3", + "transcript": "I've been feeling a bit stressed lately with all the assignments and exams coming up.", "sentiment": { - "score": 0.25, + "score": 0.31, "label": "negative", - "confidence": 0.89 + "confidence": 0.85 }, "coaching": { "breathingExercise": { - "title": "4-7-8 Calming Breath", + "title": "4-7-8 Breathing Technique", "instructions": [ - "Sit comfortably with your back straight", "Inhale through your nose for 4 counts", "Hold your breath for 7 counts", "Exhale through your mouth for 8 counts", - "Repeat this cycle 4 times" + "Repeat 4 times for maximum effect" ], - "duration": 5 + "duration": 120 }, "stretchExercise": { "title": "Neck and Shoulder Release", "instructions": [ - "Gently tilt your head to the right, hold for 15 seconds", + "Slowly roll your shoulders backward 5 times", + "Gently tilt your head to the right, hold 15 seconds", "Repeat on the left side", - "Roll your shoulders backward 5 times", - "Take deep breaths throughout" + "Take deep breaths during each stretch" ] }, "resources": [ { - "title": "Campus Counseling Center", - "description": "Free counseling services for students", - "url": "https://counseling.university.edu", + "title": "University Counseling Center", + "description": "Free confidential counseling services for students", + "url": "https://university.edu/counseling", "category": "counseling" }, { - "title": "Headspace - Study Focus", - "description": "Meditation sessions designed for students", - "url": "https://headspace.com/study", + "title": "Headspace for Students", + "description": "Free meditation and mindfulness app", + "url": "https://headspace.com/students", "category": "meditation" + }, + { + "title": "Crisis Text Line", + "description": "24/7 mental health crisis support via text", + "url": "https://crisistextline.org", + "category": "emergency" } ], - "motivationalMessage": "Remember, feeling stressed about exams is completely normal. You've prepared well, and taking breaks to breathe and stretch will actually help you focus better. You've got this!" + "motivationalMessage": "Thank you for sharing what's on your mind. It's completely normal to feel stressed about exams and assignments - many students experience this. Remember that you have the strength to handle these challenges, and taking time for self-care like breathing exercises can really help. You're taking a positive step by checking in with yourself." }, - "audioUrl": "http://localhost:4000/audio/coaching-550e8400-e29b-41d4.mp3", - "audioMetadata": { - "duration": 45.6, - "fileSize": 1024000, - "format": "mp3" - } + "audioUrl": "https://api.pulsemates.com/audio/91edb2fc-ae1b-4dde-a4ca-76b8643dd3c3.mp3" }, - "processingTime": 2347, - "timestamp": "2025-01-09T10:30:00.000Z" + "processingTime": 3876 } ``` diff --git a/apps/server/src/__tests__/checkin-endpoint.test.ts b/apps/server/src/__tests__/checkin-endpoint.test.ts new file mode 100644 index 0000000..337b467 --- /dev/null +++ b/apps/server/src/__tests__/checkin-endpoint.test.ts @@ -0,0 +1,274 @@ +import request from 'supertest'; +import path from 'path'; +import fs from 'fs'; +import app from '../app'; + +describe('POST /api/checkin', () => { + // Create a mock audio file for testing + const mockAudioFilePath = path.join(__dirname, 'mock-audio.wav'); + + beforeAll(() => { + // Create a small mock WAV file for testing + const mockWavContent = Buffer.from([ + 0x52, + 0x49, + 0x46, + 0x46, // "RIFF" + 0x24, + 0x00, + 0x00, + 0x00, // File size + 0x57, + 0x41, + 0x56, + 0x45, // "WAVE" + 0x66, + 0x6d, + 0x74, + 0x20, // "fmt " + 0x10, + 0x00, + 0x00, + 0x00, // Chunk size + 0x01, + 0x00, + 0x01, + 0x00, // Audio format and channels + 0x44, + 0xac, + 0x00, + 0x00, // Sample rate (44100) + 0x88, + 0x58, + 0x01, + 0x00, // Byte rate + 0x02, + 0x00, + 0x10, + 0x00, // Block align and bits per sample + 0x64, + 0x61, + 0x74, + 0x61, // "data" + 0x00, + 0x00, + 0x00, + 0x00, // Data size + ]); + + fs.writeFileSync(mockAudioFilePath, mockWavContent); + }); + + afterAll(() => { + // Clean up mock file + if (fs.existsSync(mockAudioFilePath)) { + fs.unlinkSync(mockAudioFilePath); + } + }); + + it('should successfully process a valid audio file', async () => { + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockAudioFilePath, 'test-audio.wav') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.transcript).toBeDefined(); + expect(response.body.data.sentiment).toBeDefined(); + expect(response.body.data.coaching).toBeDefined(); + expect(response.body.data.audioUrl).toBeDefined(); + expect(response.body.data.sessionId).toBeDefined(); + expect(response.body.processingTime).toBeGreaterThan(0); + + // Check transcript content + expect(typeof response.body.data.transcript).toBe('string'); + expect(response.body.data.transcript.length).toBeGreaterThan(0); + + // Check sentiment structure + const sentiment = response.body.data.sentiment; + expect(sentiment.score).toBeGreaterThanOrEqual(0); + expect(sentiment.score).toBeLessThanOrEqual(1); + expect(['positive', 'negative', 'neutral']).toContain(sentiment.label); + expect(sentiment.confidence).toBeGreaterThanOrEqual(0); + expect(sentiment.confidence).toBeLessThanOrEqual(1); + + // Check coaching structure + const coaching = response.body.data.coaching; + expect(coaching.breathingExercise).toBeDefined(); + expect(coaching.breathingExercise.title).toBeDefined(); + expect(Array.isArray(coaching.breathingExercise.instructions)).toBe(true); + expect(coaching.breathingExercise.duration).toBeGreaterThan(0); + + expect(coaching.stretchExercise).toBeDefined(); + expect(coaching.stretchExercise.title).toBeDefined(); + expect(Array.isArray(coaching.stretchExercise.instructions)).toBe(true); + + expect(Array.isArray(coaching.resources)).toBe(true); + coaching.resources.forEach((resource: any) => { + expect(resource.title).toBeDefined(); + expect(resource.description).toBeDefined(); + expect(resource.url).toBeDefined(); + expect(['counseling', 'meditation', 'emergency']).toContain( + resource.category + ); + }); + + expect(coaching.motivationalMessage).toBeDefined(); + expect(typeof coaching.motivationalMessage).toBe('string'); + + // Check audio URL format + expect(response.body.data.audioUrl).toMatch( + /^https:\/\/api\.pulsemates\.com\/audio\/[a-f0-9-]+\.mp3$/ + ); + }, 10000); // 10 second timeout for async processing + + it('should return 400 when no file is uploaded', async () => { + const response = await request(app).post('/api/checkin').expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('No audio file uploaded'); + }); + + it('should return 400 for invalid file format', async () => { + // Create a text file to test invalid format + const invalidFilePath = path.join(__dirname, 'invalid.txt'); + fs.writeFileSync(invalidFilePath, 'This is not an audio file'); + + const response = await request(app) + .post('/api/checkin') + .attach('audio', invalidFilePath, 'invalid.txt') + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Invalid file format'); + + // Clean up + fs.unlinkSync(invalidFilePath); + }); + + it('should return 400 for oversized file', async () => { + // Create a large file (> 10MB) + const largeFilePath = path.join(__dirname, 'large.wav'); + const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB + fs.writeFileSync(largeFilePath, largeBuffer); + + const response = await request(app) + .post('/api/checkin') + .attach('audio', largeFilePath, 'large.wav') + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('File too large'); + + // Clean up + fs.unlinkSync(largeFilePath); + }); + + it('should handle MP3 files correctly', async () => { + // Create a mock MP3 file + const mockMp3FilePath = path.join(__dirname, 'mock-audio.mp3'); + const mockMp3Content = Buffer.from([ + 0xff, + 0xfb, + 0x90, + 0x00, // MP3 header + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]); + + fs.writeFileSync(mockMp3FilePath, mockMp3Content); + + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockMp3FilePath, 'test-audio.mp3') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.transcript).toBeDefined(); + + // Clean up + fs.unlinkSync(mockMp3FilePath); + }, 10000); + + it('should include session ID for tracking', async () => { + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockAudioFilePath, 'test-audio.wav') + .expect(200); + + expect(response.body.data.sessionId).toBeDefined(); + expect(typeof response.body.data.sessionId).toBe('string'); + expect(response.body.data.sessionId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }, 10000); + + it('should have reasonable processing time', async () => { + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockAudioFilePath, 'test-audio.wav') + .expect(200); + + expect(response.body.processingTime).toBeDefined(); + expect(response.body.processingTime).toBeGreaterThan(1000); // At least 1 second (due to mock delays) + expect(response.body.processingTime).toBeLessThan(6000); // Should be under 6 seconds + }, 10000); + + it('should generate sentiment-appropriate coaching', async () => { + // Test multiple times to potentially get different sentiments + const responses = []; + + for (let i = 0; i < 3; i++) { + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockAudioFilePath, 'test-audio.wav') + .expect(200); + + responses.push(response.body); + } + + // Check that we got some variety in sentiments or appropriate coaching + responses.forEach(response => { + const sentiment = response.data.sentiment; + const coaching = response.data.coaching; + + expect(['positive', 'negative', 'neutral']).toContain(sentiment.label); + + // Coaching should exist regardless of sentiment + expect(coaching.breathingExercise.title).toBeDefined(); + expect(coaching.stretchExercise.title).toBeDefined(); + expect(coaching.motivationalMessage.length).toBeGreaterThan(10); + }); + }, 15000); + + it('should include emergency resources for negative sentiment', async () => { + // We can't guarantee negative sentiment with mock data, but we can test the structure + const response = await request(app) + .post('/api/checkin') + .attach('audio', mockAudioFilePath, 'test-audio.wav') + .expect(200); + + expect(response.body.success).toBe(true); + const resources = response.body.data.coaching.resources; + + // Should have at least 2 base resources + expect(resources.length).toBeGreaterThanOrEqual(2); + + // Check resource structure + resources.forEach((resource: any) => { + expect(resource.title).toBeDefined(); + expect(resource.description).toBeDefined(); + expect(resource.url).toBeDefined(); + expect(['counseling', 'meditation', 'emergency']).toContain( + resource.category + ); + }); + }, 10000); +}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4b65746..b88c642 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit'; import morgan from 'morgan'; import dotenv from 'dotenv'; import { z } from 'zod'; +import checkinRoutes from './routes/checkin'; // Load environment variables dotenv.config(); @@ -63,6 +64,9 @@ app.get('/api/health', (_req, res) => { }); }); +// Voice check-in routes +app.use('/api/checkin', checkinRoutes); + // User creation example endpoint app.post('/api/users', (req, res) => { const userSchema = z.object({ diff --git a/apps/server/src/middleware/upload.ts b/apps/server/src/middleware/upload.ts new file mode 100644 index 0000000..2cb03fe --- /dev/null +++ b/apps/server/src/middleware/upload.ts @@ -0,0 +1,80 @@ +import multer from 'multer'; +import path from 'path'; +import { Request } from 'express'; +import { AUDIO_UPLOAD_CONFIG } from '../types'; + +// Configure multer for audio file uploads +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + // Store files in temporary directory + cb(null, 'uploads/temp/'); + }, + filename: (_req, file, cb) => { + // Generate unique filename with timestamp + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, `audio-${uniqueSuffix}${ext}`); + }, +}); + +// File filter for audio files +const fileFilter = ( + _req: Request, + file: Express.Multer.File, + cb: multer.FileFilterCallback +) => { + const allowedExtensions = ['.wav', '.mp3', '.m4a']; + const allowedMimeTypes = [ + 'audio/wav', + 'audio/wave', + 'audio/x-wav', + 'audio/mpeg', + 'audio/mp3', + 'audio/mp4', + 'audio/x-m4a', + ]; + + const ext = path.extname(file.originalname).toLowerCase(); + const mimeType = file.mimetype.toLowerCase(); + + if (allowedExtensions.includes(ext) && allowedMimeTypes.includes(mimeType)) { + cb(null, true); + } else { + cb( + new Error( + `Invalid file format. Allowed formats: ${allowedExtensions.join(', ')}` + ) + ); + } +}; + +// Configure multer with file size limits +export const uploadAudio = multer({ + storage, + fileFilter, + limits: { + fileSize: AUDIO_UPLOAD_CONFIG.maxSize, // 10MB + files: 1, // Only one file at a time + }, +}).single('audio'); // Field name should be 'audio' + +import fs from 'fs'; + +// Cleanup function for temporary files +export const cleanupTempFile = (filePath: string): void => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`🗑️ Cleaned up temporary file: ${filePath}`); + } + } catch (error) { + console.error(`❌ Failed to cleanup file ${filePath}:`, error); + } +}; + +// Create uploads directory if it doesn't exist +const uploadsDir = 'uploads/temp'; +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + console.log(`📁 Created uploads directory: ${uploadsDir}`); +} diff --git a/apps/server/src/routes/checkin.ts b/apps/server/src/routes/checkin.ts new file mode 100644 index 0000000..c2b23a0 --- /dev/null +++ b/apps/server/src/routes/checkin.ts @@ -0,0 +1,150 @@ +import express from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { uploadAudio, cleanupTempFile } from '../middleware/upload'; +import { + MockSTTService, + MockSentimentService, + MockCoachingService, + MockTTSService, +} from '../services/mockServices'; +import { CheckinResponse, APIError } from '../types'; + +const router = express.Router(); + +// POST /api/checkin - Voice check-in endpoint +router.post('/', async (req: express.Request, res: express.Response) => { + const startTime = Date.now(); + let uploadedFilePath: string | undefined; + + try { + // Handle file upload with multer + uploadAudio(req, res, async (uploadError): Promise => { + try { + if (uploadError) { + console.error('❌ File upload error:', uploadError.message); + res.status(400).json({ + success: false, + error: uploadError.message, + timestamp: new Date().toISOString(), + } as APIError); + return; + } + + // Check if file was uploaded + if (!req.file) { + res.status(400).json({ + success: false, + error: + 'No audio file uploaded. Please include an audio file with key "audio".', + timestamp: new Date().toISOString(), + } as APIError); + return; + } + + uploadedFilePath = req.file.path; + console.log( + `📁 Audio file uploaded: ${uploadedFilePath} (${req.file.size} bytes)` + ); + + // Generate session ID for tracking + const sessionId = uuidv4(); + console.log(`🔄 Processing check-in session: ${sessionId}`); + + // Step 1: Speech-to-Text Processing + console.log('🎤 Starting speech-to-text processing...'); + const transcriptionResult = + await MockSTTService.transcribe(uploadedFilePath); + console.log( + `✅ Transcription completed: "${transcriptionResult.transcript}" (confidence: ${transcriptionResult.confidence})` + ); + + // Step 2: Sentiment Analysis + console.log('🎭 Analyzing sentiment...'); + const sentimentResult = await MockSentimentService.analyzeSentiment( + transcriptionResult.transcript + ); + console.log( + `✅ Sentiment analysis completed: ${sentimentResult.label} (score: ${sentimentResult.score})` + ); + + // Step 3: AI Coaching Generation + console.log('🤖 Generating personalized coaching...'); + const coachingResult = await MockCoachingService.generateCoaching( + sentimentResult, + transcriptionResult.transcript + ); + console.log( + `✅ Coaching generated: ${coachingResult.motivationalMessage.substring(0, 50)}...` + ); + + // Step 4: Text-to-Speech for motivational message + console.log('🔊 Converting coaching message to speech...'); + const ttsResult = await MockTTSService.generateSpeech( + coachingResult.motivationalMessage + ); + console.log( + `✅ TTS audio generated: ${ttsResult.audioUrl} (${ttsResult.duration}s)` + ); + + // Calculate total processing time + const processingTime = Date.now() - startTime; + console.log(`⚡ Total processing time: ${processingTime}ms`); + + // Build successful response + const response: CheckinResponse = { + success: true, + data: { + transcript: transcriptionResult.transcript, + sentiment: sentimentResult, + coaching: coachingResult, + audioUrl: ttsResult.audioUrl, + sessionId, + }, + processingTime, + }; + + // Cleanup uploaded file + if (uploadedFilePath) { + cleanupTempFile(uploadedFilePath); + } + + res.status(200).json(response); + console.log( + `✅ Check-in completed successfully for session ${sessionId}` + ); + } catch (processingError) { + console.error('❌ Processing error:', processingError); + + // Cleanup uploaded file in case of error + if (uploadedFilePath) { + cleanupTempFile(uploadedFilePath); + } + + res.status(500).json({ + success: false, + error: 'Internal server error during audio processing', + details: + process.env.NODE_ENV === 'development' + ? processingError + : undefined, + timestamp: new Date().toISOString(), + } as APIError); + } + }); + } catch (error) { + console.error('❌ Unexpected error:', error); + + // Cleanup uploaded file in case of error + if (uploadedFilePath) { + cleanupTempFile(uploadedFilePath); + } + + res.status(500).json({ + success: false, + error: 'Internal server error', + timestamp: new Date().toISOString(), + } as APIError); + } +}); + +export default router; diff --git a/apps/server/src/services/mockServices.ts b/apps/server/src/services/mockServices.ts new file mode 100644 index 0000000..a6839c6 --- /dev/null +++ b/apps/server/src/services/mockServices.ts @@ -0,0 +1,286 @@ +import { SentimentResult, CoachingResponse } from '../types'; +import { v4 as uuidv4 } from 'uuid'; + +// Mock Speech-to-Text Service +export class MockSTTService { + static async transcribe(_audioFilePath: string): Promise<{ + transcript: string; + confidence: number; + processingTime: number; + }> { + // Simulate processing time (500ms - 1.5s) + const processingTime = Math.random() * 1000 + 500; + await new Promise(resolve => setTimeout(resolve, processingTime)); + + // Mock transcript based on common mental health check-in phrases + const mockTranscripts = [ + "I've been feeling a bit stressed lately with all the assignments and exams coming up.", + 'Today was actually pretty good! I managed to finish my project and felt accomplished.', + "I'm struggling with anxiety about my future career and whether I'm making the right choices.", + 'Had a rough day today. Feeling overwhelmed with everything on my plate.', + "Feeling grateful for my friends and family. They've been really supportive lately.", + "I've been having trouble sleeping because my mind keeps racing about deadlines.", + 'Things are going okay, just taking it one day at a time.', + 'I feel excited about the new semester starting and the opportunities ahead.', + ]; + + const transcript = + mockTranscripts[Math.floor(Math.random() * mockTranscripts.length)]; + const confidence = Math.random() * 0.3 + 0.7; // 70-100% confidence + + return { + transcript, + confidence, + processingTime: Math.round(processingTime), + }; + } +} + +// Mock Sentiment Analysis Service +export class MockSentimentService { + static async analyzeSentiment(transcript: string): Promise { + // Simulate processing time (200ms - 800ms) + const processingTime = Math.random() * 600 + 200; + await new Promise(resolve => setTimeout(resolve, processingTime)); + + // Basic sentiment analysis based on keywords + const positiveKeywords = [ + 'good', + 'great', + 'happy', + 'excited', + 'accomplished', + 'grateful', + 'supportive', + ]; + const negativeKeywords = [ + 'stressed', + 'anxiety', + 'struggling', + 'rough', + 'overwhelmed', + 'trouble', + 'worried', + ]; + + const lowerTranscript = transcript.toLowerCase(); + const positiveCount = positiveKeywords.filter(word => + lowerTranscript.includes(word) + ).length; + const negativeCount = negativeKeywords.filter(word => + lowerTranscript.includes(word) + ).length; + + let label: 'positive' | 'negative' | 'neutral'; + let score: number; + + if (positiveCount > negativeCount) { + label = 'positive'; + score = Math.random() * 0.3 + 0.7; // 70-100% + } else if (negativeCount > positiveCount) { + label = 'negative'; + score = Math.random() * 0.3 + 0.1; // 10-40% + } else { + label = 'neutral'; + score = Math.random() * 0.4 + 0.4; // 40-80% + } + + const confidence = Math.random() * 0.2 + 0.8; // 80-100% confidence + + return { + score: Math.round(score * 100) / 100, // Round to 2 decimal places + label, + confidence: Math.round(confidence * 100) / 100, + }; + } +} + +// Mock AI Coaching Service +export class MockCoachingService { + static async generateCoaching( + sentiment: SentimentResult, + transcript: string + ): Promise { + // Simulate processing time (1s - 2s) + const processingTime = Math.random() * 1000 + 1000; + await new Promise(resolve => setTimeout(resolve, processingTime)); + + const coaching: CoachingResponse = { + breathingExercise: this.getBreathingExercise(sentiment.label), + stretchExercise: this.getStretchExercise(sentiment.label), + resources: this.getResources(sentiment.label), + motivationalMessage: this.getMotivationalMessage( + sentiment.label, + transcript + ), + }; + + return coaching; + } + + private static getBreathingExercise(sentiment: string) { + const exercises = { + positive: { + title: 'Gratitude Breathing', + instructions: [ + 'Take a comfortable seated position', + "Breathe in for 4 counts, thinking of something you're grateful for", + 'Hold for 4 counts, feeling that gratitude', + 'Exhale for 6 counts, releasing any tension', + 'Repeat 5 times', + ], + duration: 3, + }, + negative: { + title: 'Calming Breath', + instructions: [ + 'Find a quiet place to sit or lie down', + 'Place one hand on your chest, one on your belly', + 'Breathe in slowly through your nose for 4 counts', + 'Hold your breath for 7 counts', + 'Exhale completely through your mouth for 8 counts', + 'Repeat 4 times', + ], + duration: 5, + }, + neutral: { + title: 'Box Breathing', + instructions: [ + 'Sit with your back straight and feet flat on the floor', + 'Breathe in through your nose for 4 counts', + 'Hold your breath for 4 counts', + 'Exhale through your mouth for 4 counts', + 'Hold for 4 counts before starting again', + 'Continue for 2-3 minutes', + ], + duration: 4, + }, + }; + + return exercises[sentiment as keyof typeof exercises] || exercises.neutral; + } + + private static getStretchExercise(sentiment: string) { + const stretches = { + positive: { + title: 'Energy Boosting Stretch', + instructions: [ + 'Stand tall with feet hip-width apart', + 'Reach both arms overhead and stretch up', + 'Gently bend to one side, then the other', + 'Roll your shoulders back and down', + 'Take 3 deep breaths in this position', + ], + }, + negative: { + title: 'Tension Release Stretch', + instructions: [ + 'Sit comfortably in your chair', + 'Roll your shoulders up, back, and down', + 'Gently turn your head left and right', + 'Stretch your arms across your body', + 'Take slow, deep breaths with each movement', + ], + }, + neutral: { + title: 'Mindful Movement', + instructions: [ + 'Stand and take a moment to notice your posture', + 'Slowly raise your arms above your head', + 'Gently twist your torso left and right', + 'Bend forward slightly and let your arms hang', + 'Return to standing and take 3 deep breaths', + ], + }, + }; + + return stretches[sentiment as keyof typeof stretches] || stretches.neutral; + } + + private static getResources( + sentiment: string + ): CoachingResponse['resources'] { + const baseResources: CoachingResponse['resources'] = [ + { + title: 'University Counseling Center', + description: 'Free counseling services for students', + url: 'https://counseling.university.edu', + category: 'counseling', + }, + { + title: 'Mindfulness Meditation App', + description: 'Guided meditation for stress relief', + url: 'https://meditation-app.com', + category: 'meditation', + }, + ]; + + if (sentiment === 'negative') { + baseResources.push({ + title: 'Crisis Support Hotline', + description: '24/7 support for mental health crises', + url: 'tel:988', + category: 'emergency', + }); + } + + return baseResources; + } + + private static getMotivationalMessage( + sentiment: string, + _transcript: string + ) { + const messages = { + positive: [ + "It's wonderful to hear that you're feeling good! Keep nurturing that positive energy.", + 'Your positive outlook is a strength. Remember to celebrate these good moments.', + "You're doing great! This positive mindset can help you tackle any challenges ahead.", + ], + negative: [ + "Thank you for sharing how you're feeling. It takes courage to acknowledge difficult emotions.", + "Remember that it's okay to feel this way. You're not alone, and things can get better.", + 'These feelings are temporary. You have the strength to work through this challenging time.', + ], + neutral: [ + 'Taking time to check in with yourself is a healthy habit. Keep it up!', + "It sounds like you're managing things well. Remember to take care of yourself.", + "Steady progress is still progress. You're doing well by staying mindful of your mental health.", + ], + }; + + const sentimentMessages = + messages[sentiment as keyof typeof messages] || messages.neutral; + return sentimentMessages[ + Math.floor(Math.random() * sentimentMessages.length) + ]; + } +} + +// Mock Text-to-Speech Service +export class MockTTSService { + static async generateSpeech(text: string): Promise<{ + audioUrl: string; + duration: number; + fileSize: number; + format: 'mp3'; + }> { + // Simulate processing time (800ms - 1.5s) + const processingTime = Math.random() * 700 + 800; + await new Promise(resolve => setTimeout(resolve, processingTime)); + + // Simulate audio file details + const duration = Math.floor(text.length / 15); // Roughly 15 chars per second + const fileSize = duration * 32000; // Approximate file size + + // Generate a mock audio URL (in real implementation, this would be a real file) + const audioUrl = `https://api.pulsemates.com/audio/${uuidv4()}.mp3`; + + return { + audioUrl, + duration, + fileSize, + format: 'mp3', + }; + } +} diff --git a/apps/server/src/types/index.ts b/apps/server/src/types/index.ts new file mode 100644 index 0000000..2d0cb2c --- /dev/null +++ b/apps/server/src/types/index.ts @@ -0,0 +1,60 @@ +// API Response Types for PulseMates + +export interface SentimentResult { + score: number; // 0-1 normalized + label: 'positive' | 'negative' | 'neutral'; + confidence: number; +} + +export interface CoachingResponse { + breathingExercise: { + title: string; + instructions: string[]; + duration: number; + }; + stretchExercise: { + title: string; + instructions: string[]; + imageUrl?: string; + }; + resources: { + title: string; + description: string; + url: string; + category: 'counseling' | 'meditation' | 'emergency'; + }[]; + motivationalMessage: string; +} + +export interface CheckinResponse { + success: boolean; + data: { + transcript?: string; + sentiment?: SentimentResult; + coaching?: CoachingResponse; + audioUrl?: string; + sessionId: string; + }; + processingTime: number; + error?: string; +} + +export interface APIError { + success: false; + error: string; + details?: unknown; + timestamp: string; +} + +// File upload validation types +export interface AudioFileValidation { + format: 'wav' | 'mp3' | 'm4a'; + maxSize: number; // in bytes + maxDuration: number; // in seconds +} + +export const AUDIO_UPLOAD_CONFIG: AudioFileValidation = { + format: 'wav', // Allow multiple formats + maxSize: 10 * 1024 * 1024, // 10MB + maxDuration: 60, // 60 seconds +}; diff --git a/docs/postman/PulseMates-API.postman_collection.json b/docs/postman/PulseMates-API.postman_collection.json index e76c780..f3fda2a 100644 --- a/docs/postman/PulseMates-API.postman_collection.json +++ b/docs/postman/PulseMates-API.postman_collection.json @@ -173,7 +173,7 @@ } ], "cookie": [], - "body": "{\n \"success\": true,\n \"data\": {\n \"transcript\": \"I'm feeling a bit stressed about my upcoming exams\",\n \"sentiment\": {\n \"score\": 0.65,\n \"label\": \"negative\",\n \"confidence\": 0.82\n },\n \"coaching\": {\n \"breathingExercise\": {\n \"title\": \"4-7-8 Breathing Technique\",\n \"instructions\": [\n \"Inhale through your nose for 4 counts\",\n \"Hold your breath for 7 counts\",\n \"Exhale through your mouth for 8 counts\",\n \"Repeat 4 times\"\n ],\n \"duration\": 120\n },\n \"stretchExercise\": {\n \"title\": \"Neck and Shoulder Release\",\n \"instructions\": [\n \"Slowly roll your shoulders backward 5 times\",\n \"Gently tilt your head to the right, hold 15 seconds\",\n \"Repeat on the left side\"\n ]\n },\n \"resources\": [\n {\n \"title\": \"University Counseling Center\",\n \"description\": \"Free confidential counseling services\",\n \"url\": \"https://university.edu/counseling\",\n \"category\": \"counseling\"\n }\n ],\n \"motivationalMessage\": \"You're taking a great step by checking in with yourself. Remember, stress about exams is normal, and you have the tools to manage it effectively.\"\n },\n \"audioUrl\": \"https://storage.googleapis.com/tts-audio/coaching-response-12345.mp3\"\n },\n \"processingTime\": 2450,\n \"error\": null\n}" + "body": "{\n \"success\": true,\n \"data\": {\n \"sessionId\": \"91edb2fc-ae1b-4dde-a4ca-76b8643dd3c3\",\n \"transcript\": \"I've been feeling a bit stressed lately with all the assignments and exams coming up.\",\n \"sentiment\": {\n \"score\": 0.31,\n \"label\": \"negative\",\n \"confidence\": 0.85\n },\n \"coaching\": {\n \"breathingExercise\": {\n \"title\": \"4-7-8 Breathing Technique\",\n \"instructions\": [\n \"Inhale through your nose for 4 counts\",\n \"Hold your breath for 7 counts\",\n \"Exhale through your mouth for 8 counts\",\n \"Repeat 4 times for maximum effect\"\n ],\n \"duration\": 120\n },\n \"stretchExercise\": {\n \"title\": \"Neck and Shoulder Release\",\n \"instructions\": [\n \"Slowly roll your shoulders backward 5 times\",\n \"Gently tilt your head to the right, hold 15 seconds\",\n \"Repeat on the left side\",\n \"Take deep breaths during each stretch\"\n ]\n },\n \"resources\": [\n {\n \"title\": \"University Counseling Center\",\n \"description\": \"Free confidential counseling services for students\",\n \"url\": \"https://university.edu/counseling\",\n \"category\": \"counseling\"\n },\n {\n \"title\": \"Headspace for Students\",\n \"description\": \"Free meditation and mindfulness app\",\n \"url\": \"https://headspace.com/students\",\n \"category\": \"meditation\"\n },\n {\n \"title\": \"Crisis Text Line\",\n \"description\": \"24/7 mental health crisis support via text\",\n \"url\": \"https://crisistextline.org\",\n \"category\": \"emergency\"\n }\n ],\n \"motivationalMessage\": \"Thank you for sharing what's on your mind. It's completely normal to feel stressed about exams and assignments - many students experience this. Remember that you have the strength to handle these challenges, and taking time for self-care like breathing exercises can really help. You're taking a positive step by checking in with yourself.\"\n },\n \"audioUrl\": \"https://api.pulsemates.com/audio/91edb2fc-ae1b-4dde-a4ca-76b8643dd3c3.mp3\"\n },\n \"processingTime\": 3876\n}" }, { "name": "File Too Large", @@ -206,7 +206,7 @@ } ], "cookie": [], - "body": "{\n \"success\": false,\n \"error\": \"File size exceeds maximum limit of 10MB\",\n \"processingTime\": 50\n}" + "body": "{\n \"success\": false,\n \"error\": \"File too large. Maximum size allowed: 10MB\",\n \"timestamp\": \"2025-01-09T10:30:00.000Z\"\n}" }, { "name": "Invalid File Format", @@ -239,7 +239,7 @@ } ], "cookie": [], - "body": "{\n \"success\": false,\n \"error\": \"Invalid file format. Supported formats: wav, mp3, m4a\",\n \"processingTime\": 25\n}" + "body": "{\n \"success\": false,\n \"error\": \"Invalid file format. Please upload wav, mp3, or m4a files only.\",\n \"timestamp\": \"2025-01-09T10:30:00.000Z\"\n}" } ] }