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) { $text = $segment['text'] ?? ''; // 언더스코어 노이즈 제거 $text = trim(preg_replace('/\s{2,}/', ' ', str_replace('_', '', $text))); MeetingMinuteSegment::create([ 'meeting_minute_id' => $meeting->id, 'segment_order' => $index, 'speaker_name' => $segment['speaker_name'] ?? '화자 1', 'speaker_label' => $segment['speaker_label'] ?? null, 'text' => $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'; $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) 실행 * V2 + Chirp 2 우선 시도, 실패 시 V1 + latest_long 자동 폴백 */ 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->speechToTextWithDiarizationAuto( $meeting->audio_gcs_uri, $meeting->stt_language ?? 'ko-KR', $minSpeakers, $maxSpeakers, $this->getDefaultPhraseHints() ); if (! $result || empty($result['segments'])) { Log::warning('MeetingMinute: 화자 분리 결과 없음', ['meeting_id' => $meeting->id]); $meeting->update(['status' => MeetingMinute::STATUS_FAILED]); return null; } $engine = $result['engine'] ?? 'v1'; $speakerCount = $result['speaker_count'] ?? 1; $segments = $result['segments']; Log::info('MeetingMinute: STT 화자 분리 완료', [ 'meeting_id' => $meeting->id, 'engine' => $engine, 'segments' => count($segments), 'speaker_count' => $speakerCount, ]); // Google 화자분리가 1명만 인식한 경우 → Gemini AI로 화자 재분배 if ($speakerCount <= 1 && count($segments) > 0 && $minSpeakers >= 2) { $fullText = implode(' ', array_map(fn ($s) => $s['text'] ?? '', $segments)); $geminiSegments = $this->splitSpeakersWithGemini($fullText, $minSpeakers); if ($geminiSegments && count($geminiSegments) > 1) { $segments = $geminiSegments; $speakerCount = count(array_unique(array_column($segments, 'speaker_label'))); $engine .= '+gemini'; Log::info('MeetingMinute: Gemini 화자 재분배 완료', [ 'meeting_id' => $meeting->id, 'segments' => count($segments), 'speaker_count' => $speakerCount, ]); } } // 기존 세그먼트 교체 $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' => 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) { $usageLabel = $engine === 'v2' ? '회의록-화자분리(Chirp2)' : '회의록-화자분리'; AiTokenHelper::saveSttUsage($usageLabel, $meeting->duration_seconds); } return [ 'segments' => $segments, 'speaker_count' => $speakerCount, '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; } } /** * Speech Adaptation 도메인 용어 힌트 */ private function getDefaultPhraseHints(): array { return [ '블라인드', '스크린', '롤스크린', '허니콤', '버티컬', '원단', '바텀레일', '헤드레일', '브라켓', '주일', '경동', '주일블라인드', '경동블라인드', '수주', '발주', '납기', '출하', '재고', '원가', '단가', 'SAM', 'ERP', 'MES', ]; } /** * Gemini AI를 사용하여 단일 화자 텍스트를 대화 패턴으로 화자 분리 */ private function splitSpeakersWithGemini(string $fullText, int $expectedSpeakers = 2): ?array { $config = AiConfig::getActiveGemini(); if (! $config) { return null; } $prompt = <<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 || ! is_array($parsed) || count($parsed) < 2) { Log::warning('MeetingMinute: Gemini 화자 분리 파싱 실패', [ 'response' => mb_substr($responseText, 0, 500), ]); return null; } // 세그먼트 형식으로 변환 $segments = []; foreach ($parsed as $item) { $speakerNum = $item['speaker'] ?? 1; $text = trim($item['text'] ?? ''); if ($text === '') { continue; } $segments[] = [ 'speaker_name' => '화자 '.$speakerNum, 'speaker_label' => (string) $speakerNum, 'text' => $text, 'start_time_ms' => 0, 'end_time_ms' => null, 'is_manual_speaker' => false, ]; } return count($segments) >= 2 ? $segments : null; } catch (\Exception $e) { Log::error('MeetingMinute: Gemini 화자 분리 예외', ['error' => $e->getMessage()]); 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; } } }