diff --git a/app/Http/Controllers/Juil/MeetingMinuteController.php b/app/Http/Controllers/Juil/MeetingMinuteController.php new file mode 100644 index 00000000..3aece9e3 --- /dev/null +++ b/app/Http/Controllers/Juil/MeetingMinuteController.php @@ -0,0 +1,273 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('juil.meeting-minutes.index')); + } + + return view('juil.meeting-minutes'); + } + + public function list(Request $request): JsonResponse + { + $params = $request->only(['search', 'date_from', 'date_to', 'status', 'per_page']); + $meetings = $this->service->getList($params); + + return response()->json([ + 'success' => true, + 'data' => $meetings, + ]); + } + + public function show(int $id): JsonResponse + { + $meeting = MeetingMinute::with(['user', 'segments'])->find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $meeting, + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'title' => 'nullable|string|max:300', + 'folder' => 'nullable|string|max:100', + 'participants' => 'nullable|array', + 'meeting_date' => 'nullable|date', + 'meeting_time' => 'nullable', + 'stt_language' => 'nullable|string|max:10', + ]); + + $meeting = $this->service->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 생성되었습니다.', + 'data' => $meeting, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'title' => 'nullable|string|max:300', + 'folder' => 'nullable|string|max:100', + 'participants' => 'nullable|array', + 'meeting_date' => 'nullable|date', + 'meeting_time' => 'nullable', + ]); + + $meeting = $this->service->update($meeting, $validated); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 수정되었습니다.', + 'data' => $meeting, + ]); + } + + public function destroy(int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $this->service->delete($meeting); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 삭제되었습니다.', + ]); + } + + public function saveSegments(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'segments' => 'required|array', + 'segments.*.speaker_name' => 'required|string|max:100', + 'segments.*.speaker_label' => 'nullable|string|max:20', + 'segments.*.text' => 'required|string', + 'segments.*.start_time_ms' => 'nullable|integer|min:0', + 'segments.*.end_time_ms' => 'nullable|integer|min:0', + 'segments.*.is_manual_speaker' => 'nullable|boolean', + ]); + + $meeting = $this->service->saveSegments($meeting, $validated['segments']); + + return response()->json([ + 'success' => true, + 'message' => '세그먼트가 저장되었습니다.', + 'data' => $meeting, + ]); + } + + public function uploadAudio(Request $request, int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'audio' => 'required|file|max:102400', + 'duration_seconds' => 'required|integer|min:0', + ]); + + $result = $this->service->uploadAudio($meeting, $request->file('audio'), $validated['duration_seconds']); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '오디오 업로드에 실패했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '오디오가 업로드되었습니다.', + 'data' => $meeting->fresh(), + ]); + } + + public function summarize(int $id): JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + if (empty($meeting->full_transcript)) { + return response()->json([ + 'success' => false, + 'message' => '요약할 텍스트가 없습니다. 먼저 녹음을 진행해주세요.', + ], 422); + } + + $result = $this->service->generateSummary($meeting); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => 'AI 요약에 실패했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => 'AI 요약이 완료되었습니다.', + 'data' => $meeting->fresh(), + ]); + } + + public function downloadAudio(Request $request, int $id): Response|JsonResponse + { + $meeting = MeetingMinute::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + if (! $meeting->audio_file_path) { + return response()->json([ + 'success' => false, + 'message' => '오디오 파일이 없습니다.', + ], 404); + } + + $googleCloudService = app(GoogleCloudService::class); + $content = $googleCloudService->downloadFromStorage($meeting->audio_file_path); + + if (! $content) { + return response()->json([ + 'success' => false, + 'message' => '파일 다운로드에 실패했습니다.', + ], 500); + } + + $extension = pathinfo($meeting->audio_file_path, PATHINFO_EXTENSION) ?: 'webm'; + $mimeType = 'audio/' . $extension; + + $safeTitle = preg_replace('/[\/\\\\:*?"<>|]/', '_', $meeting->title); + $filename = "{$safeTitle}.{$extension}"; + $encodedFilename = rawurlencode($filename); + + return response($content) + ->header('Content-Type', $mimeType) + ->header('Content-Length', strlen($content)) + ->header('Content-Disposition', "attachment; filename=\"{$encodedFilename}\"; filename*=UTF-8''{$encodedFilename}") + ->header('Cache-Control', 'private, max-age=3600'); + } + + public function logSttUsage(Request $request): JsonResponse + { + $validated = $request->validate([ + 'duration_seconds' => 'required|integer|min:1', + ]); + + $this->service->logSttUsage($validated['duration_seconds']); + + return response()->json(['success' => true]); + } +} diff --git a/app/Models/Juil/MeetingMinute.php b/app/Models/Juil/MeetingMinute.php new file mode 100644 index 00000000..b2b405c1 --- /dev/null +++ b/app/Models/Juil/MeetingMinute.php @@ -0,0 +1,79 @@ + 'array', + 'decisions' => 'array', + 'action_items' => 'array', + 'meeting_date' => 'date', + 'duration_seconds' => 'integer', + 'audio_file_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function segments(): HasMany + { + return $this->hasMany(MeetingMinuteSegment::class)->orderBy('segment_order'); + } + + public function getFormattedDurationAttribute(): string + { + $seconds = $this->duration_seconds; + $hours = floor($seconds / 3600); + $minutes = floor(($seconds % 3600) / 60); + $secs = $seconds % 60; + + if ($hours > 0) { + return sprintf('%d:%02d:%02d', $hours, $minutes, $secs); + } + + return sprintf('%02d:%02d', $minutes, $secs); + } +} diff --git a/app/Models/Juil/MeetingMinuteSegment.php b/app/Models/Juil/MeetingMinuteSegment.php new file mode 100644 index 00000000..e2ffc8ad --- /dev/null +++ b/app/Models/Juil/MeetingMinuteSegment.php @@ -0,0 +1,36 @@ + 'integer', + 'start_time_ms' => 'integer', + 'end_time_ms' => 'integer', + 'is_manual_speaker' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function meetingMinute(): BelongsTo + { + return $this->belongsTo(MeetingMinute::class); + } +} diff --git a/app/Services/MeetingMinuteService.php b/app/Services/MeetingMinuteService.php new file mode 100644 index 00000000..17964a68 --- /dev/null +++ b/app/Services/MeetingMinuteService.php @@ -0,0 +1,423 @@ +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); + } + + 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; + } + } +} diff --git a/resources/views/juil/meeting-minutes.blade.php b/resources/views/juil/meeting-minutes.blade.php new file mode 100644 index 00000000..265b686f --- /dev/null +++ b/resources/views/juil/meeting-minutes.blade.php @@ -0,0 +1,876 @@ +@extends('layouts.app') + +@section('title', '회의록 작성') + +@section('content') +
+@endsection + +@push('scripts') + + + + + +@endpush diff --git a/routes/web.php b/routes/web.php index 460444e2..edf3deb4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,7 @@ use App\Http\Controllers\RolePermissionController; use App\Http\Controllers\Sales\SalesProductController; use App\Http\Controllers\Juil\ConstructionSitePhotoController; +use App\Http\Controllers\Juil\MeetingMinuteController; use App\Http\Controllers\Juil\PlanningController; use App\Http\Controllers\Stats\StatDashboardController; use App\Http\Controllers\System\AiConfigController; @@ -1327,4 +1328,19 @@ Route::get('/{id}/download/{type}', [ConstructionSitePhotoController::class, 'downloadPhoto'])->name('download'); Route::post('/log-stt-usage', [ConstructionSitePhotoController::class, 'logSttUsage'])->name('log-stt-usage'); }); + + // 회의록 작성 + Route::prefix('meeting-minutes')->name('meeting-minutes.')->group(function () { + Route::get('/', [MeetingMinuteController::class, 'index'])->name('index'); + Route::get('/list', [MeetingMinuteController::class, 'list'])->name('list'); + Route::post('/', [MeetingMinuteController::class, 'store'])->name('store'); + Route::post('/log-stt-usage', [MeetingMinuteController::class, 'logSttUsage'])->name('log-stt-usage'); + Route::get('/{id}', [MeetingMinuteController::class, 'show'])->name('show'); + Route::put('/{id}', [MeetingMinuteController::class, 'update'])->name('update'); + Route::delete('/{id}', [MeetingMinuteController::class, 'destroy'])->name('destroy'); + Route::post('/{id}/segments', [MeetingMinuteController::class, 'saveSegments'])->name('save-segments'); + Route::post('/{id}/upload-audio', [MeetingMinuteController::class, 'uploadAudio'])->name('upload-audio'); + Route::post('/{id}/summarize', [MeetingMinuteController::class, 'summarize'])->name('summarize'); + Route::get('/{id}/download-audio', [MeetingMinuteController::class, 'downloadAudio'])->name('download-audio'); + }); });