feat:회의록 자동 화자 분리(Phase 2) 구현 및 세그먼트 저장 에러 수정

- GoogleCloudService에 speechToTextWithDiarization 메서드 추가
- Google STT V1 diarizationConfig 활성화로 자동 화자 구분
- MeetingMinuteService에 processDiarization 메서드 추가
- POST /{id}/diarize 엔드포인트 및 라우트 추가
- 프론트엔드에 '화자 분리' 버튼 추가 (RecordingControlBar)
- saveSegments 컨트롤러에 try-catch 에러 핸들링 추가
- 빈 텍스트 세그먼트 필터링 로직 추가 (서버/클라이언트 양쪽)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-10 10:29:16 +09:00
parent 0f312bcf77
commit b2fbd3d113
5 changed files with 418 additions and 20 deletions

View File

@@ -135,21 +135,46 @@ public function saveSegments(Request $request, int $id): JsonResponse
$validated = $request->validate([
'segments' => 'required|array',
'segments.*.speaker_name' => 'required|string|max:100',
'segments.*.speaker_name' => 'nullable|string|max:100',
'segments.*.speaker_label' => 'nullable|string|max:20',
'segments.*.text' => 'required|string',
'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',
]);
$meeting = $this->service->saveSegments($meeting, $validated['segments']);
// 빈 텍스트 세그먼트 필터링
$segments = array_values(array_filter($validated['segments'], function ($seg) {
return ! empty(trim($seg['text'] ?? ''));
}));
return response()->json([
'success' => true,
'message' => '세그먼트가 저장되었습니다.',
'data' => $meeting,
]);
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
@@ -218,6 +243,44 @@ public function summarize(int $id): JsonResponse
]);
}
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);