Add voice command review/edit system with user feedback tracking
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

Implemented complete review/edit workflow for voice commands with ML feedback collection:

**Backend:**
- Created V012 migration for voice_feedback table with user action tracking
- Added VoiceFeedback entity with approval/edit/reject actions
- Implemented voice feedback API endpoint (POST /api/v1/voice/feedback)
- Fixed user ID extraction bug (req.user.userId vs req.user.sub)

**Frontend:**
- Built VoiceActivityReview component with field-specific editors
- Integrated review dialog into voice command workflow
- Added approve/edit/reject handlers with feedback submission
- Fixed infinite loop by tracking processed classification IDs

**Features:**
- Users can review AI-extracted data before saving
- Quick-edit capabilities for all activity fields
- Feedback data stored for ML model improvement
- Activity creation only happens after user approval/edit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-02 11:03:54 +00:00
parent 77f2c1d767
commit e94a1018c4
10 changed files with 743 additions and 79 deletions

View File

@@ -14,4 +14,5 @@ export {
NotificationStatus,
NotificationPriority,
} from './notification.entity';
export { Photo, PhotoType } from './photo.entity';
export { Photo, PhotoType } from './photo.entity';
export { VoiceFeedback, VoiceFeedbackAction } from './voice-feedback.entity';

View File

@@ -0,0 +1,76 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';
import { Child } from './child.entity';
import { Activity } from './activity.entity';
export enum VoiceFeedbackAction {
APPROVED = 'approved',
EDITED = 'edited',
REJECTED = 'rejected',
}
@Entity('voice_feedback')
export class VoiceFeedback {
@PrimaryColumn({ type: 'varchar', length: 20 })
id: string;
@Column({ name: 'user_id', type: 'varchar', length: 20 })
userId: string;
@Column({ name: 'child_id', type: 'varchar', length: 20, nullable: true })
childId?: string;
@Column({ name: 'activity_id', type: 'varchar', length: 20, nullable: true })
activityId?: string;
@Column({ type: 'text' })
transcript: string;
@Column({ type: 'varchar', length: 10, nullable: true })
language?: string;
@Column({ name: 'extracted_type', type: 'varchar', length: 20 })
extractedType: string;
@Column({ name: 'extracted_data', type: 'jsonb', default: {} })
extractedData: Record<string, any>;
@Column({ type: 'decimal', precision: 3, scale: 2, nullable: true })
confidence?: number;
@Column({
type: 'varchar',
length: 20,
enum: VoiceFeedbackAction,
})
action: VoiceFeedbackAction;
@Column({ name: 'final_type', type: 'varchar', length: 20, nullable: true })
finalType?: string;
@Column({ name: 'final_data', type: 'jsonb', nullable: true })
finalData?: Record<string, any>;
@Column({ name: 'user_notes', type: 'text', nullable: true })
userNotes?: string;
@Column({
name: 'created_at',
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
})
createdAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Child, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'child_id' })
child?: Child;
@ManyToOne(() => Activity, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'activity_id' })
activity?: Activity;
}

View File

@@ -0,0 +1,43 @@
-- V012: Create voice feedback table for storing user corrections and approvals
-- This helps improve AI accuracy by learning from user edits
CREATE TABLE IF NOT EXISTS voice_feedback (
id VARCHAR(20) PRIMARY KEY,
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
child_id VARCHAR(20) REFERENCES children(id) ON DELETE CASCADE,
activity_id VARCHAR(20) REFERENCES activities(id) ON DELETE CASCADE,
-- Original voice input
transcript TEXT NOT NULL,
language VARCHAR(10),
-- AI extracted data (before user edits)
extracted_type VARCHAR(20) NOT NULL,
extracted_data JSONB NOT NULL DEFAULT '{}',
confidence DECIMAL(3,2),
-- User actions
action VARCHAR(20) NOT NULL CHECK (action IN ('approved', 'edited', 'rejected')),
-- Final data (after user edits, if edited)
final_type VARCHAR(20),
final_data JSONB,
-- User feedback
user_notes TEXT,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for analytics
CREATE INDEX idx_voice_feedback_user ON voice_feedback(user_id, created_at DESC);
CREATE INDEX idx_voice_feedback_type ON voice_feedback(extracted_type, action);
CREATE INDEX idx_voice_feedback_confidence ON voice_feedback(confidence, action);
CREATE INDEX idx_voice_feedback_activity ON voice_feedback(activity_id);
-- Add comment
COMMENT ON TABLE voice_feedback IS 'Stores user feedback on voice command accuracy for ML improvement';
COMMENT ON COLUMN voice_feedback.action IS 'User action: approved (no changes), edited (modified data), rejected (discarded)';
COMMENT ON COLUMN voice_feedback.extracted_data IS 'Original AI-extracted activity data before user edits';
COMMENT ON COLUMN voice_feedback.final_data IS 'User-modified data if action=edited';

View File

@@ -0,0 +1,50 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsObject, IsNumber, Min, Max } from 'class-validator';
import { VoiceFeedbackAction } from '../../../database/entities';
export class SaveVoiceFeedbackDto {
@IsString()
@IsOptional()
childId?: string;
@IsString()
@IsOptional()
activityId?: string;
@IsString()
@IsNotEmpty()
transcript: string;
@IsString()
@IsOptional()
language?: string;
@IsString()
@IsNotEmpty()
extractedType: string;
@IsObject()
@IsNotEmpty()
extractedData: Record<string, any>;
@IsNumber()
@Min(0)
@Max(1)
@IsOptional()
confidence?: number;
@IsEnum(VoiceFeedbackAction)
@IsNotEmpty()
action: VoiceFeedbackAction;
@IsString()
@IsOptional()
finalType?: string;
@IsObject()
@IsOptional()
finalData?: Record<string, any>;
@IsString()
@IsOptional()
userNotes?: string;
}

View File

@@ -7,10 +7,13 @@ import {
Req,
BadRequestException,
Logger,
UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VoiceService } from './voice.service';
import { Public } from '../auth/decorators/public.decorator';
import { SaveVoiceFeedbackDto } from './dto/save-voice-feedback.dto';
@Controller('api/v1/voice')
export class VoiceController {
@@ -183,4 +186,22 @@ export class VoiceController {
classification: result,
};
}
@Post('feedback')
@UseGuards(JwtAuthGuard)
async saveFeedback(
@Req() req: any,
@Body() feedbackDto: SaveVoiceFeedbackDto,
) {
const userId = req.user.userId;
this.logger.log(`[Voice Feedback] User ${userId} submitting feedback: ${feedbackDto.action}`);
const feedback = await this.voiceService.saveFeedback(userId, feedbackDto);
return {
success: true,
data: feedback,
};
}
}

View File

@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { VoiceService } from './voice.service';
import { VoiceController } from './voice.controller';
import { VoiceFeedback } from '../../database/entities';
@Module({
imports: [TypeOrmModule.forFeature([VoiceFeedback])],
controllers: [VoiceController],
providers: [VoiceService],
exports: [VoiceService],

View File

@@ -1,8 +1,13 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import OpenAI from 'openai';
import * as fs from 'fs';
import * as path from 'path';
import { nanoid } from 'nanoid';
import { VoiceFeedback } from '../../database/entities';
import { SaveVoiceFeedbackDto } from './dto/save-voice-feedback.dto';
export interface TranscriptionResult {
text: string;
@@ -26,7 +31,11 @@ export class VoiceService {
// Supported languages for MVP
private readonly SUPPORTED_LANGUAGES = ['en', 'es', 'fr', 'pt', 'zh'];
constructor(private configService: ConfigService) {
constructor(
private configService: ConfigService,
@InjectRepository(VoiceFeedback)
private readonly voiceFeedbackRepository: Repository<VoiceFeedback>,
) {
// Check if Azure OpenAI is enabled
const azureEnabled = this.configService.get<boolean>('AZURE_OPENAI_ENABLED');
@@ -328,4 +337,44 @@ Respond ONLY with the question text, no formatting.`;
return 'Could you provide more details about this activity?';
}
}
/**
* Save user feedback on voice command accuracy
*/
async saveFeedback(
userId: string,
feedbackDto: SaveVoiceFeedbackDto,
): Promise<VoiceFeedback> {
try {
const feedback = this.voiceFeedbackRepository.create({
id: `vfb_${nanoid(16)}`,
userId,
childId: feedbackDto.childId,
activityId: feedbackDto.activityId,
transcript: feedbackDto.transcript,
language: feedbackDto.language,
extractedType: feedbackDto.extractedType,
extractedData: feedbackDto.extractedData,
confidence: feedbackDto.confidence,
action: feedbackDto.action,
finalType: feedbackDto.finalType,
finalData: feedbackDto.finalData,
userNotes: feedbackDto.userNotes,
});
const saved = await this.voiceFeedbackRepository.save(feedback);
this.logger.log(
`[Voice Feedback] User ${userId} ${feedbackDto.action} voice command for type: ${feedbackDto.extractedType}`,
);
return saved;
} catch (error) {
this.logger.error(
`[Voice Feedback] Failed to save: ${error.message}`,
error.stack,
);
throw new BadRequestException('Failed to save voice feedback');
}
}
}