diff --git a/app/Http/Controllers/System/AiVoiceRecordingController.php b/app/Http/Controllers/System/AiVoiceRecordingController.php new file mode 100644 index 00000000..de7efedd --- /dev/null +++ b/app/Http/Controllers/System/AiVoiceRecordingController.php @@ -0,0 +1,204 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('system.ai-voice-recording.index')); + } + + return view('system.ai-voice-recording.index'); + } + + /** + * JSON 목록 + */ + public function list(Request $request): JsonResponse + { + $params = $request->only(['search', 'status', 'per_page']); + $recordings = $this->service->getList($params); + + return response()->json([ + 'success' => true, + 'data' => $recordings, + ]); + } + + /** + * 새 녹음 생성 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'nullable|string|max:200', + ]); + + $recording = $this->service->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '녹음이 생성되었습니다.', + 'data' => $recording, + ], 201); + } + + /** + * Base64 오디오 업로드 + 처리 + */ + public function processAudio(Request $request, int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'audio' => 'required|string', + 'duration' => 'required|integer|min:1', + ]); + + $result = $this->service->processAudio( + $recording, + $validated['audio'], + $validated['duration'] + ); + + if (! $result['ok']) { + return response()->json([ + 'success' => false, + 'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '음성 분석이 완료되었습니다.', + 'data' => $result['recording'], + ]); + } + + /** + * 파일 업로드 + 처리 + */ + public function uploadFile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'audio_file' => 'required|file|mimes:webm,wav,mp3,ogg,m4a,mp4|max:102400', + 'title' => 'nullable|string|max:200', + ]); + + $recording = $this->service->create([ + 'title' => $validated['title'] ?? '업로드된 음성녹음', + ]); + + $result = $this->service->processUploadedFile( + $recording, + $request->file('audio_file') + ); + + if (! $result['ok']) { + return response()->json([ + 'success' => false, + 'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '음성 분석이 완료되었습니다.', + 'data' => $result['recording'], + ]); + } + + /** + * 상세 조회 + */ + public function show(int $id): JsonResponse + { + $recording = $this->service->getById($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $recording, + ]); + } + + /** + * 삭제 + */ + public function destroy(int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + $this->service->delete($recording); + + return response()->json([ + 'success' => true, + 'message' => '녹음이 삭제되었습니다.', + ]); + } + + /** + * 처리 상태 폴링용 + */ + public function status(int $id): JsonResponse + { + $recording = AiVoiceRecording::find($id); + + if (! $recording) { + return response()->json([ + 'success' => false, + 'message' => '녹음을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $recording->id, + 'status' => $recording->status, + 'status_label' => $recording->status_label, + 'is_completed' => $recording->isCompleted(), + 'is_processing' => $recording->isProcessing(), + 'transcript_text' => $recording->transcript_text, + 'analysis_text' => $recording->analysis_text, + ], + ]); + } +} diff --git a/app/Models/AiVoiceRecording.php b/app/Models/AiVoiceRecording.php new file mode 100644 index 00000000..9a36cb7c --- /dev/null +++ b/app/Models/AiVoiceRecording.php @@ -0,0 +1,94 @@ + 'integer', + 'file_expiry_date' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public const STATUS_PENDING = 'PENDING'; + + public const STATUS_PROCESSING = 'PROCESSING'; + + public const STATUS_COMPLETED = 'COMPLETED'; + + public const STATUS_FAILED = 'FAILED'; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getFormattedDurationAttribute(): string + { + if (! $this->duration_seconds) { + return '00:00'; + } + + $minutes = floor($this->duration_seconds / 60); + $seconds = $this->duration_seconds % 60; + + return sprintf('%02d:%02d', $minutes, $seconds); + } + + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기중', + self::STATUS_PROCESSING => '처리중', + self::STATUS_COMPLETED => '완료', + self::STATUS_FAILED => '실패', + default => $this->status, + }; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'badge-warning', + self::STATUS_PROCESSING => 'badge-info', + self::STATUS_COMPLETED => 'badge-success', + self::STATUS_FAILED => 'badge-error', + default => 'badge-ghost', + }; + } + + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + public function isProcessing(): bool + { + return $this->status === self::STATUS_PROCESSING; + } +} diff --git a/app/Services/AiVoiceRecordingService.php b/app/Services/AiVoiceRecordingService.php new file mode 100644 index 00000000..99a6b73d --- /dev/null +++ b/app/Services/AiVoiceRecordingService.php @@ -0,0 +1,475 @@ +with('user:id,name') + ->orderBy('created_at', 'desc'); + + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('transcript_text', 'like', "%{$search}%") + ->orWhere('analysis_text', 'like', "%{$search}%"); + }); + } + + $perPage = $params['per_page'] ?? 10; + + return $query->paginate($perPage); + } + + /** + * 상세 조회 + */ + public function getById(int $id): ?AiVoiceRecording + { + return AiVoiceRecording::with('user:id,name')->find($id); + } + + /** + * 새 녹음 레코드 생성 + */ + public function create(array $data): AiVoiceRecording + { + return AiVoiceRecording::create([ + 'tenant_id' => session('selected_tenant_id'), + 'user_id' => Auth::id(), + 'title' => $data['title'] ?? '무제 음성녹음', + 'status' => AiVoiceRecording::STATUS_PENDING, + 'file_expiry_date' => now()->addDays(7), + ]); + } + + /** + * Base64 오디오 업로드 + 처리 파이프라인 + */ + public function processAudio(AiVoiceRecording $recording, string $audioBase64, int $durationSeconds): array + { + try { + $recording->update([ + 'status' => AiVoiceRecording::STATUS_PROCESSING, + 'duration_seconds' => $durationSeconds, + ]); + + // 1. GCS에 오디오 업로드 + $objectName = sprintf( + 'voice-recordings/%d/%d/%s.webm', + $recording->tenant_id, + $recording->id, + now()->format('YmdHis') + ); + + $gcsUri = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName); + + if (! $gcsUri) { + throw new \Exception('오디오 파일 업로드 실패'); + } + + $recording->update([ + 'audio_file_path' => $objectName, + 'audio_gcs_uri' => $gcsUri, + ]); + + // 2. Speech-to-Text 변환 + $transcript = $this->googleCloudService->speechToText($gcsUri); + + if (! $transcript) { + throw new \Exception('음성 인식 실패'); + } + + $recording->update(['transcript_text' => $transcript]); + + // 3. Gemini AI 분석 + $analysis = $this->analyzeWithGemini($transcript); + + $recording->update([ + 'analysis_text' => $analysis, + 'status' => AiVoiceRecording::STATUS_COMPLETED, + ]); + + return [ + 'ok' => true, + 'recording' => $recording->fresh(), + ]; + } catch (\Exception $e) { + Log::error('AiVoiceRecording 처리 실패', [ + 'recording_id' => $recording->id, + 'error' => $e->getMessage(), + ]); + + $recording->update(['status' => AiVoiceRecording::STATUS_FAILED]); + + return [ + 'ok' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * 파일 업로드 처리 + */ + public function processUploadedFile(AiVoiceRecording $recording, UploadedFile $file): array + { + try { + $recording->update(['status' => AiVoiceRecording::STATUS_PROCESSING]); + + // 임시 저장 + $tempPath = $file->store('temp', 'local'); + $fullPath = storage_path('app/' . $tempPath); + + // 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준) + $fileSize = $file->getSize(); + $estimatedDuration = max(1, intval($fileSize / 12000)); + $recording->update(['duration_seconds' => $estimatedDuration]); + + // 1. GCS에 오디오 업로드 + $extension = $file->getClientOriginalExtension() ?: 'webm'; + $objectName = sprintf( + 'voice-recordings/%d/%d/%s.%s', + $recording->tenant_id, + $recording->id, + now()->format('YmdHis'), + $extension + ); + + $gcsUri = $this->googleCloudService->uploadToStorage($fullPath, $objectName); + + if (! $gcsUri) { + @unlink($fullPath); + throw new \Exception('오디오 파일 업로드 실패'); + } + + $recording->update([ + 'audio_file_path' => $objectName, + 'audio_gcs_uri' => $gcsUri, + ]); + + // 2. Speech-to-Text 변환 + $transcript = $this->googleCloudService->speechToText($gcsUri); + + // 임시 파일 삭제 + @unlink($fullPath); + + if (! $transcript) { + throw new \Exception('음성 인식 실패'); + } + + $recording->update(['transcript_text' => $transcript]); + + // 3. Gemini AI 분석 + $analysis = $this->analyzeWithGemini($transcript); + + $recording->update([ + 'analysis_text' => $analysis, + 'status' => AiVoiceRecording::STATUS_COMPLETED, + ]); + + return [ + 'ok' => true, + 'recording' => $recording->fresh(), + ]; + } catch (\Exception $e) { + Log::error('AiVoiceRecording 파일 처리 실패', [ + 'recording_id' => $recording->id, + 'error' => $e->getMessage(), + ]); + + $recording->update(['status' => AiVoiceRecording::STATUS_FAILED]); + + return [ + 'ok' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Gemini API로 영업 시나리오 분석 + */ + private function analyzeWithGemini(string $transcript): ?string + { + $config = AiConfig::getActiveGemini(); + + if (! $config) { + Log::warning('Gemini API 설정이 없습니다.'); + return null; + } + + $prompt = $this->buildAnalysisPrompt($transcript); + + if ($config->isVertexAi()) { + return $this->callVertexAiApi($config, $prompt); + } + + return $this->callGoogleAiStudioApi($config, $prompt); + } + + /** + * Google AI Studio API 호출 + */ + private function callGoogleAiStudioApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $apiKey = $config->api_key; + $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; + + $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; + + return $this->callGeminiApi($url, $prompt, [ + 'Content-Type' => 'application/json', + ], false); + } + + /** + * Vertex AI API 호출 + */ + private function callVertexAiApi(AiConfig $config, string $prompt): ?string + { + $model = $config->model; + $projectId = $config->getProjectId(); + $region = $config->getRegion(); + + if (! $projectId) { + Log::error('Vertex AI 프로젝트 ID가 설정되지 않았습니다.'); + return null; + } + + $accessToken = $this->getAccessToken($config); + if (! $accessToken) { + Log::error('Google Cloud 인증 실패'); + return null; + } + + $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + + return $this->callGeminiApi($url, $prompt, [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ], true); + } + + /** + * Gemini API 공통 호출 로직 + */ + private function callGeminiApi(string $url, string $prompt, array $headers, bool $isVertexAi = false): ?string + { + $content = [ + 'parts' => [ + ['text' => $prompt], + ], + ]; + + if ($isVertexAi) { + $content['role'] = 'user'; + } + + try { + $response = Http::timeout(120) + ->withHeaders($headers) + ->post($url, [ + 'contents' => [$content], + 'generationConfig' => [ + 'temperature' => 0.3, + 'topK' => 40, + 'topP' => 0.95, + 'maxOutputTokens' => 4096, + ], + ]); + + if (! $response->successful()) { + Log::error('Gemini API error', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + $result = $response->json(); + + // 토큰 사용량 저장 + AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', 'AI음성녹음분석'); + + return $result['candidates'][0]['content']['parts'][0]['text'] ?? null; + } catch (\Exception $e) { + Log::error('Gemini API 예외', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * 서비스 계정으로 OAuth2 액세스 토큰 가져오기 + */ + private function getAccessToken(AiConfig $config): ?string + { + $configuredPath = $config->getServiceAccountPath(); + + $possiblePaths = array_filter([ + $configuredPath, + '/var/www/sales/apikey/google_service_account.json', + storage_path('app/google_service_account.json'), + ]); + + $serviceAccountPath = null; + foreach ($possiblePaths as $path) { + if ($path && file_exists($path)) { + $serviceAccountPath = $path; + break; + } + } + + if (! $serviceAccountPath) { + Log::error('Service account file not found', ['tried_paths' => $possiblePaths]); + return null; + } + + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (! $serviceAccount) { + Log::error('Service account JSON parse failed'); + return null; + } + + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/cloud-platform', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (! $privateKey) { + Log::error('Failed to load private key'); + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + try { + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if ($response->successful()) { + return $response->json()['access_token'] ?? null; + } + + Log::error('OAuth token request failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } catch (\Exception $e) { + Log::error('OAuth token request exception', ['error' => $e->getMessage()]); + return null; + } + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * 영업 시나리오 분석 프롬프트 + */ + private function buildAnalysisPrompt(string $transcript): string + { + return <<audio_file_path) { + $this->googleCloudService->deleteFromStorage($recording->audio_file_path); + } + + return $recording->delete(); + } + + /** + * 만료된 파일 정리 (Cron용) + */ + public function cleanupExpiredFiles(): int + { + $expired = AiVoiceRecording::where('file_expiry_date', '<=', now()) + ->whereNotNull('audio_file_path') + ->get(); + + $count = 0; + foreach ($expired as $recording) { + if ($recording->audio_file_path) { + $this->googleCloudService->deleteFromStorage($recording->audio_file_path); + $recording->update([ + 'audio_file_path' => null, + 'audio_gcs_uri' => null, + ]); + $count++; + } + } + + return $count; + } +} diff --git a/database/seeders/AiTokenUsageMenuSeeder.php b/database/seeders/AiTokenUsageMenuSeeder.php index e5f39563..905c9d4c 100644 --- a/database/seeders/AiTokenUsageMenuSeeder.php +++ b/database/seeders/AiTokenUsageMenuSeeder.php @@ -81,6 +81,34 @@ public function run(): void $this->command->info("AI 토큰 사용량 메뉴가 이미 AI 관리 그룹에 있습니다."); } + // 4. AI 음성녹음 메뉴 생성 또는 이동 + $aiVoice = Menu::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('name', 'AI 음성녹음') + ->whereNull('deleted_at') + ->first(); + + if ($aiVoice && $aiVoice->parent_id !== $aiGroup->id) { + $aiVoice->update([ + 'parent_id' => $aiGroup->id, + 'sort_order' => 3, + ]); + $this->command->info("AI 음성녹음 메뉴를 AI 관리 그룹으로 이동 완료"); + } elseif (! $aiVoice) { + Menu::withoutGlobalScopes()->create([ + 'tenant_id' => $tenantId, + 'parent_id' => $aiGroup->id, + 'name' => 'AI 음성녹음', + 'url' => '/system/ai-voice-recording', + 'icon' => 'mic', + 'sort_order' => 3, + 'is_active' => true, + ]); + $this->command->info("AI 음성녹음 메뉴 생성 완료"); + } else { + $this->command->info("AI 음성녹음 메뉴가 이미 AI 관리 그룹에 있습니다."); + } + // 결과 출력 $this->command->info(''); $this->command->info('=== AI 관리 하위 메뉴 ==='); diff --git a/resources/views/system/ai-voice-recording/index.blade.php b/resources/views/system/ai-voice-recording/index.blade.php new file mode 100644 index 00000000..7e979102 --- /dev/null +++ b/resources/views/system/ai-voice-recording/index.blade.php @@ -0,0 +1,798 @@ +@extends('layouts.app') + +@section('title', 'AI 음성녹음') + +@push('styles') + +@endpush + +@section('content') +
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 12a40bf5..06a71d36 100644 --- a/routes/web.php +++ b/routes/web.php @@ -35,6 +35,7 @@ use App\Http\Controllers\Sales\SalesProductController; use App\Http\Controllers\System\AiConfigController; use App\Http\Controllers\System\AiTokenUsageController; +use App\Http\Controllers\System\AiVoiceRecordingController; use App\Http\Controllers\System\HolidayController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\SystemAlertController; @@ -411,6 +412,18 @@ Route::get('/list', [AiTokenUsageController::class, 'list'])->name('list'); }); + // AI 음성녹음 관리 + Route::prefix('system/ai-voice-recording')->name('system.ai-voice-recording.')->group(function () { + Route::get('/', [AiVoiceRecordingController::class, 'index'])->name('index'); + Route::get('/list', [AiVoiceRecordingController::class, 'list'])->name('list'); + Route::post('/', [AiVoiceRecordingController::class, 'store'])->name('store'); + Route::post('/upload', [AiVoiceRecordingController::class, 'uploadFile'])->name('upload'); + Route::get('/{id}', [AiVoiceRecordingController::class, 'show'])->name('show'); + Route::post('/{id}/process', [AiVoiceRecordingController::class, 'processAudio'])->name('process'); + Route::delete('/{id}', [AiVoiceRecordingController::class, 'destroy'])->name('destroy'); + Route::get('/{id}/status', [AiVoiceRecordingController::class, 'status'])->name('status'); + }); + // 명함 OCR API Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');