Voice commands now create activities directly via API
- Replace navigation to pre-filled forms with direct API activity creation - Fetch children from family and use first child (can be enhanced for name matching) - Show success/error messages with proper feedback - Auto-close dialog after successful save - Add test endpoint /api/v1/voice/test-classify for easy testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { VoiceService } from './voice.service';
|
import { VoiceService } from './voice.service';
|
||||||
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
|
||||||
@Controller('api/v1/voice')
|
@Controller('api/v1/voice')
|
||||||
export class VoiceController {
|
export class VoiceController {
|
||||||
@@ -150,4 +151,36 @@ export class VoiceController {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test endpoint for voice classification (public, for development/testing only)
|
||||||
|
* IMPORTANT: Remove @Public() decorator in production
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Post('test-classify')
|
||||||
|
async testClassify(
|
||||||
|
@Body('text') text: string,
|
||||||
|
@Body('language') language?: string,
|
||||||
|
@Body('childName') childName?: string,
|
||||||
|
) {
|
||||||
|
if (!text) {
|
||||||
|
throw new BadRequestException('Text is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[TEST] Voice classification request: "${text}"`);
|
||||||
|
|
||||||
|
const result = await this.voiceService.extractActivityFromText(
|
||||||
|
text,
|
||||||
|
language || 'en',
|
||||||
|
childName,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[TEST] Classification result: ${JSON.stringify(result, null, 2)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
transcript: text,
|
||||||
|
classification: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,9 @@ import MicIcon from '@mui/icons-material/Mic';
|
|||||||
import MicOffIcon from '@mui/icons-material/MicOff';
|
import MicOffIcon from '@mui/icons-material/MicOff';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useVoiceInput } from '@/hooks/useVoiceInput';
|
import { useVoiceInput } from '@/hooks/useVoiceInput';
|
||||||
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
import { trackingApi } from '@/lib/api/tracking';
|
||||||
|
import { childrenApi } from '@/lib/api/children';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating voice input button
|
* Floating voice input button
|
||||||
@@ -30,6 +33,7 @@ import { useVoiceInput } from '@/hooks/useVoiceInput';
|
|||||||
*/
|
*/
|
||||||
export function VoiceFloatingButton() {
|
export function VoiceFloatingButton() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [classificationResult, setClassificationResult] = useState<any>(null);
|
const [classificationResult, setClassificationResult] = useState<any>(null);
|
||||||
@@ -44,6 +48,8 @@ export function VoiceFloatingButton() {
|
|||||||
severity: 'info',
|
severity: 'info',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const familyId = user?.families?.[0]?.familyId;
|
||||||
|
|
||||||
const { isListening, isSupported, transcript, classification, error, usesFallback, startListening, stopListening, reset } =
|
const { isListening, isSupported, transcript, classification, error, usesFallback, startListening, stopListening, reset } =
|
||||||
useVoiceInput();
|
useVoiceInput();
|
||||||
|
|
||||||
@@ -133,7 +139,7 @@ export function VoiceFloatingButton() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClassifiedIntent = (result: any) => {
|
const handleClassifiedIntent = async (result: any) => {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setSnackbar({
|
setSnackbar({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -153,36 +159,68 @@ export function VoiceFloatingButton() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success message with activity type
|
// Get the first child from the family
|
||||||
const activityLabel = result.type.charAt(0).toUpperCase() + result.type.slice(1);
|
if (!familyId) {
|
||||||
setSnackbar({
|
setSnackbar({
|
||||||
open: true,
|
open: true,
|
||||||
message: `${activityLabel} activity detected!`,
|
message: 'No family found. Please set up your profile first.',
|
||||||
severity: 'success',
|
severity: 'error',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-close dialog and navigate with pre-filled data
|
try {
|
||||||
setTimeout(() => {
|
setIsProcessing(true);
|
||||||
handleClose();
|
|
||||||
|
|
||||||
// Encode the details as URL parameters for pre-filling the form
|
// Fetch children
|
||||||
const params = new URLSearchParams();
|
const children = await childrenApi.getChildren(familyId);
|
||||||
if (result.details) {
|
if (children.length === 0) {
|
||||||
Object.entries(result.details).forEach(([key, value]) => {
|
setSnackbar({
|
||||||
if (value !== null && value !== undefined) {
|
open: true,
|
||||||
params.set(key, String(value));
|
message: 'No children found. Please add a child first.',
|
||||||
}
|
severity: 'error',
|
||||||
});
|
});
|
||||||
}
|
setIsProcessing(false);
|
||||||
if (result.timestamp) {
|
return;
|
||||||
params.set('timestamp', result.timestamp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
// Use the first child (or you could enhance this to support child name matching)
|
||||||
const url = `/track/${result.type}${queryString ? `?${queryString}` : ''}`;
|
const childId = children[0].id;
|
||||||
|
|
||||||
router.push(url);
|
// Create the activity
|
||||||
}, 1500);
|
const activityData = {
|
||||||
|
type: result.type,
|
||||||
|
timestamp: result.timestamp || new Date().toISOString(),
|
||||||
|
data: result.details || {},
|
||||||
|
notes: result.details?.notes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Voice] Creating activity:', activityData);
|
||||||
|
|
||||||
|
await trackingApi.createActivity(childId, activityData);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const activityLabel = result.type.charAt(0).toUpperCase() + result.type.slice(1);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: `${activityLabel} activity saved successfully!`,
|
||||||
|
severity: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-close dialog
|
||||||
|
setTimeout(() => {
|
||||||
|
handleClose();
|
||||||
|
}, 1500);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Voice] Failed to create activity:', error);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: error.response?.data?.message || 'Failed to save activity. Please try again.',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseSnackbar = () => {
|
const handleCloseSnackbar = () => {
|
||||||
|
|||||||
148
test-voice-commands.js
Executable file
148
test-voice-commands.js
Executable file
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice Command Testing Script
|
||||||
|
* Tests the voice classification API with various baby care commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL || 'http://localhost:3020';
|
||||||
|
const ENDPOINT = '/api/v1/voice/test-classify'; // Using public test endpoint
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test commands
|
||||||
|
const commands = [
|
||||||
|
'Change wet diaper',
|
||||||
|
'Baby ate 150ml formula',
|
||||||
|
'Baby slept for 1 hour',
|
||||||
|
'Alice slept for 30 min',
|
||||||
|
'Alice ate 3 pcs of broccoli at 11:00 AM',
|
||||||
|
'Dirty diaper change',
|
||||||
|
'Fed baby 120ml',
|
||||||
|
'Baby napped for 45 minutes',
|
||||||
|
'Changed diaper, it was wet',
|
||||||
|
'Gave baby vitamin D drops',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function testCommand(command, testNum) {
|
||||||
|
console.log(`${colors.yellow}Test #${testNum}: "${command}"${colors.reset}`);
|
||||||
|
console.log('---');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}${ENDPOINT}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: command,
|
||||||
|
language: 'en',
|
||||||
|
childName: 'Alice',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
console.log(`${colors.red}✗ API returned error${colors.reset}`);
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
console.log('');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract classification details
|
||||||
|
const { type = 'unknown', confidence = 0, details = {}, timestamp = null } = data.classification || {};
|
||||||
|
|
||||||
|
// Color-code based on type
|
||||||
|
let typeColor;
|
||||||
|
switch (type) {
|
||||||
|
case 'feeding':
|
||||||
|
typeColor = colors.green;
|
||||||
|
break;
|
||||||
|
case 'sleep':
|
||||||
|
typeColor = colors.blue;
|
||||||
|
break;
|
||||||
|
case 'diaper':
|
||||||
|
typeColor = colors.yellow;
|
||||||
|
break;
|
||||||
|
case 'medicine':
|
||||||
|
typeColor = colors.cyan;
|
||||||
|
break;
|
||||||
|
case 'milestone':
|
||||||
|
typeColor = colors.green;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
typeColor = colors.red;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
console.log(`Type: ${typeColor}${type}${colors.reset}`);
|
||||||
|
console.log(`Confidence: ${confidence}`);
|
||||||
|
console.log(`Timestamp: ${timestamp || 'null'}`);
|
||||||
|
console.log('Details:');
|
||||||
|
console.log(JSON.stringify(details, null, 2));
|
||||||
|
|
||||||
|
// Validate confidence threshold
|
||||||
|
const passed = type !== 'unknown' && confidence >= 0.3;
|
||||||
|
|
||||||
|
if (passed) {
|
||||||
|
console.log(`${colors.green}✓ Command successfully classified${colors.reset}\n`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`${colors.red}✗ Low confidence or unknown type${colors.reset}\n`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}✗ Request failed: ${error.message}${colors.reset}\n`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log(`${colors.blue}========================================${colors.reset}`);
|
||||||
|
console.log(`${colors.blue}Voice Command Testing Suite${colors.reset}`);
|
||||||
|
console.log(`${colors.blue}========================================${colors.reset}\n`);
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < commands.length; i++) {
|
||||||
|
const result = await testCommand(commands[i], i + 1);
|
||||||
|
if (result) {
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(`${colors.blue}========================================${colors.reset}`);
|
||||||
|
console.log(`${colors.blue}Test Summary${colors.reset}`);
|
||||||
|
console.log(`${colors.blue}========================================${colors.reset}`);
|
||||||
|
console.log(`Total: ${commands.length}`);
|
||||||
|
console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
|
||||||
|
console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
console.log(`${colors.green}All tests passed! 🎉${colors.reset}`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(`${colors.red}Some tests failed. Check the output above.${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
runTests().catch(error => {
|
||||||
|
console.error(`${colors.red}Fatal error: ${error.message}${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
139
test-voice-commands.sh
Executable file
139
test-voice-commands.sh
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Voice Command Testing Script
|
||||||
|
# Tests the voice classification API with various baby care commands
|
||||||
|
|
||||||
|
API_URL="${API_URL:-http://localhost:3020}"
|
||||||
|
ENDPOINT="/api/v1/voice/transcribe"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Voice Command Testing Suite${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}\n"
|
||||||
|
|
||||||
|
# Test commands
|
||||||
|
declare -a commands=(
|
||||||
|
"Change wet diaper"
|
||||||
|
"Baby ate 150ml formula"
|
||||||
|
"Baby slept for 1 hour"
|
||||||
|
"Alice slept for 30 min"
|
||||||
|
"Alice ate 3 pcs of broccoli at 11:00 AM"
|
||||||
|
"Dirty diaper change"
|
||||||
|
"Fed baby 120ml"
|
||||||
|
"Baby napped for 45 minutes"
|
||||||
|
"Changed diaper, it was wet"
|
||||||
|
"Gave baby vitamin D drops"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Function to test a command
|
||||||
|
test_command() {
|
||||||
|
local command="$1"
|
||||||
|
local test_num="$2"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Test #$test_num: \"$command\"${NC}"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
# Make API request
|
||||||
|
response=$(curl -s -X POST "${API_URL}${ENDPOINT}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"$command\",\"language\":\"en\",\"childName\":\"Alice\"}")
|
||||||
|
|
||||||
|
# Check if request was successful
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}✗ API request failed${NC}\n"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
success=$(echo "$response" | jq -r '.success // false')
|
||||||
|
|
||||||
|
if [ "$success" != "true" ]; then
|
||||||
|
echo -e "${RED}✗ API returned error${NC}"
|
||||||
|
echo "$response" | jq '.'
|
||||||
|
echo ""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract classification details
|
||||||
|
type=$(echo "$response" | jq -r '.classification.type // "unknown"')
|
||||||
|
confidence=$(echo "$response" | jq -r '.classification.confidence // 0')
|
||||||
|
details=$(echo "$response" | jq -r '.classification.details // {}')
|
||||||
|
timestamp=$(echo "$response" | jq -r '.classification.timestamp // "null"')
|
||||||
|
|
||||||
|
# Color-code based on type
|
||||||
|
case "$type" in
|
||||||
|
feeding)
|
||||||
|
type_color="${GREEN}"
|
||||||
|
;;
|
||||||
|
sleep)
|
||||||
|
type_color="${BLUE}"
|
||||||
|
;;
|
||||||
|
diaper)
|
||||||
|
type_color="${YELLOW}"
|
||||||
|
;;
|
||||||
|
medicine)
|
||||||
|
type_color="${RED}"
|
||||||
|
;;
|
||||||
|
milestone)
|
||||||
|
type_color="${GREEN}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
type_color="${RED}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Display results
|
||||||
|
echo -e "Type: ${type_color}${type}${NC}"
|
||||||
|
echo -e "Confidence: ${confidence}"
|
||||||
|
echo -e "Timestamp: ${timestamp}"
|
||||||
|
echo "Details:"
|
||||||
|
echo "$details" | jq '.'
|
||||||
|
|
||||||
|
# Validate confidence threshold
|
||||||
|
confidence_float=$(echo "$confidence" | awk '{print ($1 >= 0.3) ? "pass" : "fail"}')
|
||||||
|
|
||||||
|
if [ "$type" != "unknown" ] && [ "$confidence_float" == "pass" ]; then
|
||||||
|
echo -e "${GREEN}✓ Command successfully classified${NC}\n"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Low confidence or unknown type${NC}\n"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
total_tests=${#commands[@]}
|
||||||
|
passed=0
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
for i in "${!commands[@]}"; do
|
||||||
|
test_num=$((i + 1))
|
||||||
|
if test_command "${commands[$i]}" "$test_num"; then
|
||||||
|
((passed++))
|
||||||
|
else
|
||||||
|
((failed++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Test Summary${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "Total: $total_tests"
|
||||||
|
echo -e "${GREEN}Passed: $passed${NC}"
|
||||||
|
echo -e "${RED}Failed: $failed${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $failed -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All tests passed! 🎉${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some tests failed. Check the output above.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user