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'] ?? '무제 음성녹음', 'interview_template_id' => $data['interview_template_id'] ?? null, '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') ); $uploadResult = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName); if (! $uploadResult) { throw new \Exception('오디오 파일 업로드 실패'); } $gcsUri = $uploadResult['uri']; $fileSize = $uploadResult['size'] ?? 0; $recording->update([ 'audio_file_path' => $objectName, 'audio_gcs_uri' => $gcsUri, ]); // GCS 업로드 토큰 사용량 기록 AiTokenHelper::saveGcsStorageUsage('AI음성녹음-GCS저장', $fileSize); // 2. Speech-to-Text 변환 Log::info('AiVoiceRecording STT 시작', ['recording_id' => $recording->id, 'gcs_uri' => $gcsUri]); $transcript = $this->googleCloudService->speechToText($gcsUri); Log::info('AiVoiceRecording STT 결과', ['recording_id' => $recording->id, 'transcript_length' => strlen($transcript ?? ''), 'transcript_preview' => mb_substr($transcript ?? '', 0, 100)]); if (! $transcript) { throw new \Exception('음성 인식 실패: STT 결과가 비어있습니다 (transcript=' . var_export($transcript, true) . ')'); } // STT 토큰 사용량 기록 AiTokenHelper::saveSttUsage('AI음성녹음-STT변환', $durationSeconds); $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 ); $uploadResult = $this->googleCloudService->uploadToStorage($fullPath, $objectName); if (! $uploadResult) { @unlink($fullPath); throw new \Exception('오디오 파일 업로드 실패'); } $gcsUri = $uploadResult['uri']; $uploadedSize = $uploadResult['size'] ?? $fileSize; $recording->update([ 'audio_file_path' => $objectName, 'audio_gcs_uri' => $gcsUri, ]); // GCS 업로드 토큰 사용량 기록 AiTokenHelper::saveGcsStorageUsage('AI음성녹음-GCS저장', $uploadedSize); // 2. Speech-to-Text 변환 $transcript = $this->googleCloudService->speechToText($gcsUri); // 임시 파일 삭제 @unlink($fullPath); if (! $transcript) { throw new \Exception('음성 인식 실패'); } // STT 토큰 사용량 기록 AiTokenHelper::saveSttUsage('AI음성녹음-STT변환', $estimatedDuration); $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; } }