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' => 'nullable|string|max:100', 'segments.*.speaker_label' => 'nullable|string|max:20', 'segments.*.text' => 'nullable|string', 'segments.*.start_time_ms' => 'nullable|integer|min:0', 'segments.*.end_time_ms' => 'nullable|integer|min:0', 'segments.*.is_manual_speaker' => 'nullable|boolean', ]); // 빈 텍스트 세그먼트 필터링 $segments = array_values(array_filter($validated['segments'], function ($seg) { return ! empty(trim($seg['text'] ?? '')); })); if (empty($segments)) { return response()->json([ 'success' => false, 'message' => '저장할 세그먼트가 없습니다.', ], 422); } try { $meeting = $this->service->saveSegments($meeting, $segments); return response()->json([ 'success' => true, 'message' => '세그먼트가 저장되었습니다.', 'data' => $meeting, ]); } catch (\Exception $e) { \Illuminate\Support\Facades\Log::error('MeetingMinute: 세그먼트 저장 실패', [ 'meeting_id' => $id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'success' => false, 'message' => '세그먼트 저장 중 오류가 발생했습니다: '.$e->getMessage(), ], 500); } } 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 diarize(Request $request, int $id): JsonResponse { $meeting = MeetingMinute::find($id); if (! $meeting) { return response()->json([ 'success' => false, 'message' => '회의록을 찾을 수 없습니다.', ], 404); } if (empty($meeting->audio_gcs_uri)) { return response()->json([ 'success' => false, 'message' => '오디오 파일이 없습니다. 먼저 녹음을 진행해주세요.', ], 422); } $minSpeakers = (int) ($request->input('min_speakers', 2)); $maxSpeakers = (int) ($request->input('max_speakers', 6)); $result = $this->service->processDiarization($meeting, $minSpeakers, $maxSpeakers); if (! $result) { return response()->json([ 'success' => false, 'message' => '자동 화자 분리에 실패했습니다.', ], 500); } return response()->json([ 'success' => true, 'message' => "자동 화자 분리가 완료되었습니다. ({$result['speaker_count']}명 감지)", 'data' => $meeting->fresh()->load('segments'), 'speaker_count' => $result['speaker_count'], ]); } 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]); } }