orderBy('meeting_date', 'desc') ->orderBy('id', 'desc'); if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('full_transcript', 'like', "%{$search}%"); }); } if (! empty($params['date_from'])) { $query->where('meeting_date', '>=', $params['date_from']); } if (! empty($params['date_to'])) { $query->where('meeting_date', '<=', $params['date_to']); } if (! empty($params['status'])) { $query->where('status', $params['status']); } $perPage = (int) ($params['per_page'] ?? 12); return $query->paginate($perPage); } public function create(array $data): MeetingMinute { return MeetingMinute::create([ 'tenant_id' => session('selected_tenant_id'), 'user_id' => Auth::id(), 'title' => $data['title'] ?? '무제 회의록', 'folder' => $data['folder'] ?? null, 'participants' => $data['participants'] ?? null, 'meeting_date' => $data['meeting_date'] ?? now()->toDateString(), 'meeting_time' => $data['meeting_time'] ?? now()->format('H:i'), 'status' => MeetingMinute::STATUS_DRAFT, 'stt_language' => $data['stt_language'] ?? 'ko-KR', ]); } public function update(MeetingMinute $meeting, array $data): MeetingMinute { $meeting->update(array_filter([ 'title' => $data['title'] ?? null, 'folder' => array_key_exists('folder', $data) ? $data['folder'] : null, 'participants' => array_key_exists('participants', $data) ? $data['participants'] : null, 'meeting_date' => $data['meeting_date'] ?? null, 'meeting_time' => $data['meeting_time'] ?? null, ], fn ($v) => $v !== null)); return $meeting->fresh(); } public function delete(MeetingMinute $meeting): bool { if ($meeting->audio_file_path) { $this->googleCloudService->deleteFromStorage($meeting->audio_file_path); } return $meeting->delete(); } public function saveSegments(MeetingMinute $meeting, array $segments): MeetingMinute { // 기존 세그먼트 삭제 후 새로 생성 $meeting->segments()->delete(); $fullTranscript = ''; foreach ($segments as $index => $segment) { MeetingMinuteSegment::create([ 'meeting_minute_id' => $meeting->id, 'segment_order' => $index, 'speaker_name' => $segment['speaker_name'] ?? '화자 1', 'speaker_label' => $segment['speaker_label'] ?? null, 'text' => $segment['text'] ?? '', 'start_time_ms' => $segment['start_time_ms'] ?? 0, 'end_time_ms' => $segment['end_time_ms'] ?? null, 'is_manual_speaker' => $segment['is_manual_speaker'] ?? true, ]); $speakerName = $segment['speaker_name'] ?? '화자 1'; $text = $segment['text'] ?? ''; $fullTranscript .= "[{$speakerName}] {$text}\n"; } $meeting->update([ 'full_transcript' => trim($fullTranscript), 'status' => MeetingMinute::STATUS_DRAFT, ]); return $meeting->fresh()->load('segments'); } public function uploadAudio(MeetingMinute $meeting, $file, int $durationSeconds): bool { $extension = $file->getClientOriginalExtension() ?: 'webm'; $objectName = sprintf( 'meeting-minutes/%d/%d/%s.%s', $meeting->tenant_id, $meeting->id, now()->format('YmdHis'), $extension ); $tempPath = $file->getRealPath(); $result = $this->googleCloudService->uploadToStorage($tempPath, $objectName); if (! $result) { Log::error('MeetingMinute: GCS 오디오 업로드 실패', [ 'meeting_id' => $meeting->id, ]); return false; } $meeting->update([ 'audio_file_path' => $objectName, 'audio_gcs_uri' => $result['uri'], 'audio_file_size' => $result['size'] ?? $file->getSize(), 'duration_seconds' => $durationSeconds, ]); AiTokenHelper::saveGcsStorageUsage('회의록-GCS저장', $result['size'] ?? $file->getSize()); return true; } public function generateSummary(MeetingMinute $meeting): ?array { if (empty($meeting->full_transcript)) { return null; } $meeting->update(['status' => MeetingMinute::STATUS_PROCESSING]); $config = AiConfig::getActiveGemini(); if (! $config) { Log::warning('Gemini API 설정이 없습니다.'); $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } $prompt = $this->buildSummaryPrompt($meeting->full_transcript); try { $result = $this->callGeminiForSummary($config, $prompt); if (! $result) { $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } $meeting->update([ 'summary' => $result['summary'] ?? null, 'decisions' => $result['decisions'] ?? [], 'action_items' => $result['action_items'] ?? [], 'status' => MeetingMinute::STATUS_COMPLETED, ]); return $result; } catch (\Exception $e) { Log::error('MeetingMinute: Gemini 요약 실패', [ 'meeting_id' => $meeting->id, 'error' => $e->getMessage(), ]); $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } } public function logSttUsage(int $durationSeconds): void { AiTokenHelper::saveSttUsage('회의록-음성인식', $durationSeconds); } /** * 업로드된 오디오에 대해 자동 화자 분리(Speaker Diarization) 실행 */ public function processDiarization(MeetingMinute $meeting, int $minSpeakers = 2, int $maxSpeakers = 6): ?array { if (empty($meeting->audio_gcs_uri)) { return null; } $meeting->update(['status' => MeetingMinute::STATUS_PROCESSING]); try { $result = $this->googleCloudService->speechToTextWithDiarization( $meeting->audio_gcs_uri, $meeting->stt_language ?? 'ko-KR', $minSpeakers, $maxSpeakers ); if (! $result || empty($result['segments'])) { Log::warning('MeetingMinute: 화자 분리 결과 없음', ['meeting_id' => $meeting->id]); $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } // 기존 세그먼트 교체 $meeting->segments()->delete(); $fullTranscript = ''; foreach ($result['segments'] as $index => $segment) { MeetingMinuteSegment::create([ 'meeting_minute_id' => $meeting->id, 'segment_order' => $index, 'speaker_name' => $segment['speaker_name'] ?? '화자 1', 'speaker_label' => $segment['speaker_label'] ?? null, 'text' => $segment['text'] ?? '', 'start_time_ms' => $segment['start_time_ms'] ?? 0, 'end_time_ms' => $segment['end_time_ms'] ?? null, 'is_manual_speaker' => false, ]); $speakerName = $segment['speaker_name'] ?? '화자 1'; $text = $segment['text'] ?? ''; $fullTranscript .= "[{$speakerName}] {$text}\n"; } $meeting->update([ 'full_transcript' => trim($fullTranscript), 'status' => MeetingMinute::STATUS_DRAFT, ]); // STT 사용량 기록 if ($meeting->duration_seconds > 0) { AiTokenHelper::saveSttUsage('회의록-화자분리', $meeting->duration_seconds); } return [ 'segments' => $result['segments'], 'speaker_count' => $result['speaker_count'] ?? 1, 'full_transcript' => trim($fullTranscript), ]; } catch (\Exception $e) { Log::error('MeetingMinute: 화자 분리 실패', [ 'meeting_id' => $meeting->id, 'error' => $e->getMessage(), ]); $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } } private function buildSummaryPrompt(string $transcript): string { return <<isVertexAi()) { $responseText = $this->callVertexAiApi($config, $prompt); } else { $responseText = $this->callGoogleAiStudioApi($config, $prompt); } if (! $responseText) { return null; } // JSON 파싱 (코드블록 제거) $cleaned = preg_replace('/```json\s*/', '', $responseText); $cleaned = preg_replace('/```\s*/', '', $cleaned); $cleaned = trim($cleaned); $parsed = json_decode($cleaned, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::warning('MeetingMinute: Gemini JSON 파싱 실패', [ 'response' => mb_substr($responseText, 0, 500), ]); return [ 'summary' => $responseText, 'decisions' => [], 'action_items' => [], 'keywords' => [], ]; } return $parsed; } 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); } 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); } 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) { 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) { 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; } return null; } catch (\Exception $e) { Log::error('OAuth token request exception', ['error' => $e->getMessage()]); return null; } } private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } 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('MeetingMinute 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('MeetingMinute Gemini API 예외', ['error' => $e->getMessage()]); return null; } } }