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('summary_text', 'like', "%{$search}%"); }); } $perPage = $params['per_page'] ?? 10; return $query->paginate($perPage); } /** * 회의록 상세 조회 */ public function getById(int $id): ?MeetingLog { return MeetingLog::with('user:id,name')->find($id); } /** * 회의록 생성 (녹음 시작) */ public function create(array $data): MeetingLog { return MeetingLog::create([ 'tenant_id' => session('selected_tenant_id'), 'user_id' => Auth::id(), 'title' => $data['title'] ?? '무제 회의록', 'status' => MeetingLog::STATUS_PENDING, 'file_expiry_date' => now()->addDays(7), ]); } /** * 오디오 업로드 및 처리 시작 */ public function processAudio(MeetingLog $meeting, string $audioBase64, int $durationSeconds): array { try { // 상태 업데이트 $meeting->update([ 'status' => MeetingLog::STATUS_PROCESSING, 'duration_seconds' => $durationSeconds, ]); // 1. GCS에 오디오 업로드 $objectName = sprintf( 'meetings/%d/%d/%s.webm', $meeting->tenant_id, $meeting->id, now()->format('YmdHis') ); $uploadResult = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName); if (! $uploadResult) { throw new \Exception('오디오 파일 업로드 실패'); } $gcsUri = $uploadResult['uri']; $meeting->update([ 'audio_file_path' => $objectName, 'audio_gcs_uri' => $gcsUri, ]); // 2. Speech-to-Text 변환 $transcript = $this->googleCloudService->speechToText($gcsUri); if (! $transcript) { throw new \Exception('음성 인식 실패'); } $meeting->update(['transcript_text' => $transcript]); // 3. AI 요약 생성 $summary = $this->generateSummary($transcript); $meeting->update([ 'summary_text' => $summary, 'status' => MeetingLog::STATUS_COMPLETED, ]); return [ 'ok' => true, 'meeting' => $meeting->fresh(), ]; } catch (\Exception $e) { Log::error('MeetingLog 처리 실패', [ 'meeting_id' => $meeting->id, 'error' => $e->getMessage(), ]); $meeting->update(['status' => MeetingLog::STATUS_FAILED]); return [ 'ok' => false, 'error' => $e->getMessage(), ]; } } /** * AI 요약 생성 (Claude API) * * @param string $summaryType meeting|work-memo */ private function generateSummary(string $transcript, string $summaryType = 'meeting'): ?string { $apiKey = config('services.claude.api_key'); if (empty($apiKey)) { Log::warning('Claude API 키 미설정'); return null; } $prompt = $summaryType === 'work-memo' ? $this->buildWorkMemoPrompt($transcript) : $this->buildSummaryPrompt($transcript); try { $response = Http::withHeaders([ 'x-api-key' => $apiKey, 'anthropic-version' => '2023-06-01', ])->post('https://api.anthropic.com/v1/messages', [ 'model' => 'claude-3-haiku-20240307', 'max_tokens' => 4096, 'messages' => [ [ 'role' => 'user', 'content' => $prompt, ], ], ]); if ($response->successful()) { $data = $response->json(); // 토큰 사용량 저장 AiTokenHelper::saveClaudeUsage($data, 'claude-3-haiku-20240307', '회의록AI요약'); return $data['content'][0]['text'] ?? null; } Log::error('Claude API 요청 실패', ['response' => $response->body()]); return null; } catch (\Exception $e) { Log::error('Claude API 예외', ['error' => $e->getMessage()]); return null; } } /** * 요약 프롬프트 생성 (일반 회의록) */ private function buildSummaryPrompt(string $transcript): string { return <<audio_file_path) { $this->googleCloudService->deleteFromStorage($meeting->audio_file_path); } return $meeting->delete(); } /** * 제목 업데이트 */ public function updateTitle(MeetingLog $meeting, string $title): MeetingLog { $meeting->update(['title' => $title]); return $meeting->fresh(); } /** * 만료된 파일 정리 (Cron Job용) */ public function cleanupExpiredFiles(): int { $expired = MeetingLog::where('file_expiry_date', '<=', now()) ->whereNotNull('audio_file_path') ->get(); $count = 0; foreach ($expired as $meeting) { if ($meeting->audio_file_path) { $this->googleCloudService->deleteFromStorage($meeting->audio_file_path); $meeting->update([ 'audio_file_path' => null, 'audio_gcs_uri' => null, ]); $count++; } } return $count; } /** * 업로드된 오디오 파일 처리 (회의록 AI 요약) * * @param string $summaryType meeting|work-memo */ public function processUploadedFile(MeetingLog $meeting, \Illuminate\Http\UploadedFile $file, string $summaryType = 'meeting'): array { try { $meeting->update(['status' => MeetingLog::STATUS_PROCESSING]); // 임시 저장 $tempPath = $file->store('temp', 'local'); $fullPath = storage_path('app/'.$tempPath); // 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준) $fileSize = $file->getSize(); $estimatedDuration = max(1, intval($fileSize / 12000)); $meeting->update(['duration_seconds' => $estimatedDuration]); // 1. GCS에 오디오 업로드 $extension = $file->getClientOriginalExtension() ?: 'webm'; $objectName = sprintf( 'meetings/%d/%d/%s.%s', $meeting->tenant_id, $meeting->id, now()->format('YmdHis'), $extension ); $uploadResult = $this->googleCloudService->uploadToStorage($fullPath, $objectName); if (! $uploadResult) { @unlink($fullPath); throw new \Exception('오디오 파일 업로드 실패'); } $gcsUri = $uploadResult['uri']; $meeting->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('음성 인식 실패'); } $meeting->update(['transcript_text' => $transcript]); // 3. AI 요약 생성 (summaryType에 따라 프롬프트 선택) $summary = $this->generateSummary($transcript, $summaryType); $meeting->update([ 'summary_text' => $summary, 'status' => MeetingLog::STATUS_COMPLETED, ]); return [ 'ok' => true, 'meeting' => $meeting->fresh(), ]; } catch (\Exception $e) { Log::error('MeetingLog 파일 처리 실패', [ 'meeting_id' => $meeting->id, 'error' => $e->getMessage(), ]); $meeting->update(['status' => MeetingLog::STATUS_FAILED]); return [ 'ok' => false, 'error' => $e->getMessage(), ]; } } }