From 331eaebf860e25d0955d996e263bde5712d67c9b Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 16 Dec 2025 15:07:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=B9=20=EB=85=B9=EC=9D=8C=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeetingLog 모델 (BelongsToTenant, SoftDeletes) - GoogleCloudService (GCS 업로드, STT API) - MeetingLogService (Claude API 요약) - MeetingLogController (HTMX/JSON 듀얼 응답) - 순수 Tailwind CSS UI 구현 - API 라우트 8개 엔드포인트 등록 --- .../Api/Admin/MeetingLogController.php | 217 +++++++ app/Models/MeetingLog.php | 119 ++++ app/Services/GoogleCloudService.php | 307 ++++++++++ app/Services/MeetingLogService.php | 249 ++++++++ config/services.php | 5 + .../views/lab/ai/web-recording.blade.php | 558 ++++++++++++++++-- .../ai/web-recording/partials/list.blade.php | 60 ++ .../web-recording/partials/summary.blade.php | 98 +++ routes/api.php | 36 ++ 9 files changed, 1606 insertions(+), 43 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/MeetingLogController.php create mode 100644 app/Models/MeetingLog.php create mode 100644 app/Services/GoogleCloudService.php create mode 100644 app/Services/MeetingLogService.php create mode 100644 resources/views/lab/ai/web-recording/partials/list.blade.php create mode 100644 resources/views/lab/ai/web-recording/partials/summary.blade.php diff --git a/app/Http/Controllers/Api/Admin/MeetingLogController.php b/app/Http/Controllers/Api/Admin/MeetingLogController.php new file mode 100644 index 00000000..a1c44cc2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MeetingLogController.php @@ -0,0 +1,217 @@ +only(['search', 'status', 'per_page']); + $meetings = $this->meetingLogService->getList($params); + + if ($request->header('HX-Request')) { + return view('lab.ai.web-recording.partials.list', compact('meetings')); + } + + return response()->json([ + 'success' => true, + 'data' => $meetings, + ]); + } + + /** + * 회의록 상세 조회 + */ + public function show(int $id): JsonResponse + { + $meeting = $this->meetingLogService->getById($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:200', + ]); + + $meeting = $this->meetingLogService->create($validated); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 생성되었습니다.', + 'data' => $meeting, + ], 201); + } + + /** + * 오디오 업로드 및 처리 + */ + public function processAudio(Request $request, int $id): JsonResponse + { + $meeting = MeetingLog::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'audio' => 'required|string', // Base64 인코딩된 오디오 + 'duration' => 'required|integer|min:1', // 녹음 시간(초) + ]); + + $result = $this->meetingLogService->processAudio( + $meeting, + $validated['audio'], + $validated['duration'] + ); + + if (! $result['ok']) { + return response()->json([ + 'success' => false, + 'message' => $result['error'] ?? '처리 중 오류가 발생했습니다.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'message' => '회의록이 생성되었습니다.', + 'data' => $result['meeting'], + ]); + } + + /** + * 제목 업데이트 + */ + public function updateTitle(Request $request, int $id): JsonResponse + { + $meeting = MeetingLog::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'title' => 'required|string|max:200', + ]); + + $meeting = $this->meetingLogService->updateTitle($meeting, $validated['title']); + + return response()->json([ + 'success' => true, + 'message' => '제목이 업데이트되었습니다.', + 'data' => $meeting, + ]); + } + + /** + * 회의록 삭제 + */ + public function destroy(int $id): JsonResponse + { + $meeting = MeetingLog::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + $this->meetingLogService->delete($meeting); + + return response()->json([ + 'success' => true, + 'message' => '회의록이 삭제되었습니다.', + ]); + } + + /** + * 처리 상태 확인 (폴링용) + */ + public function status(int $id): JsonResponse + { + $meeting = MeetingLog::find($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'id' => $meeting->id, + 'status' => $meeting->status, + 'status_label' => $meeting->status_label, + 'is_completed' => $meeting->isCompleted(), + 'is_processing' => $meeting->isProcessing(), + ], + ]); + } + + /** + * 요약 결과 조회 (HTMX용) + */ + public function summary(int $id): View|JsonResponse + { + $meeting = $this->meetingLogService->getById($id); + + if (! $meeting) { + return response()->json([ + 'success' => false, + 'message' => '회의록을 찾을 수 없습니다.', + ], 404); + } + + if (request()->header('HX-Request')) { + return view('lab.ai.web-recording.partials.summary', compact('meeting')); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'transcript' => $meeting->transcript_text, + 'summary' => $meeting->summary_text, + ], + ]); + } +} diff --git a/app/Models/MeetingLog.php b/app/Models/MeetingLog.php new file mode 100644 index 00000000..fd276d58 --- /dev/null +++ b/app/Models/MeetingLog.php @@ -0,0 +1,119 @@ + 'integer', + 'file_expiry_date' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 상태 상수 + */ + public const STATUS_PENDING = 'PENDING'; + + public const STATUS_PROCESSING = 'PROCESSING'; + + public const STATUS_COMPLETED = 'COMPLETED'; + + public const STATUS_FAILED = 'FAILED'; + + /** + * 작성자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 녹음 시간 포맷 (MM:SS) + */ + public function getFormattedDurationAttribute(): string + { + if (! $this->duration_seconds) { + return '00:00'; + } + + $minutes = floor($this->duration_seconds / 60); + $seconds = $this->duration_seconds % 60; + + return sprintf('%02d:%02d', $minutes, $seconds); + } + + /** + * 상태 레이블 + */ + public function getStatusLabelAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => '대기중', + self::STATUS_PROCESSING => '처리중', + self::STATUS_COMPLETED => '완료', + self::STATUS_FAILED => '실패', + default => $this->status, + }; + } + + /** + * 상태 색상 (Tailwind CSS) + */ + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'badge-warning', + self::STATUS_PROCESSING => 'badge-info', + self::STATUS_COMPLETED => 'badge-success', + self::STATUS_FAILED => 'badge-error', + default => 'badge-ghost', + }; + } + + /** + * 처리 완료 여부 + */ + public function isCompleted(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + /** + * 처리 중 여부 + */ + public function isProcessing(): bool + { + return $this->status === self::STATUS_PROCESSING; + } +} diff --git a/app/Services/GoogleCloudService.php b/app/Services/GoogleCloudService.php new file mode 100644 index 00000000..1774a4b8 --- /dev/null +++ b/app/Services/GoogleCloudService.php @@ -0,0 +1,307 @@ +loadServiceAccount(); + } + + /** + * 서비스 계정 로드 + */ + private function loadServiceAccount(): void + { + $path = config('services.google.credentials_path'); + + if ($path && file_exists($path)) { + $this->serviceAccount = json_decode(file_get_contents($path), true); + } + } + + /** + * OAuth 토큰 발급 + */ + private function getAccessToken(): ?string + { + // 캐시된 토큰이 유효하면 재사용 + if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) { + return $this->accessToken; + } + + if (! $this->serviceAccount) { + Log::error('Google Cloud: 서비스 계정 파일이 없습니다.'); + + return null; + } + + try { + $now = time(); + $jwtHeader = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = base64_encode(json_encode([ + 'iss' => $this->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($this->serviceAccount['private_key']); + if (! $privateKey) { + Log::error('Google Cloud: 개인 키 읽기 실패'); + + return null; + } + + openssl_sign($jwtHeader.'.'.$jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + + $jwt = $jwtHeader.'.'.$jwtClaim.'.'.base64_encode($signature); + + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]); + + if ($response->successful()) { + $data = $response->json(); + $this->accessToken = $data['access_token']; + $this->tokenExpiry = $now + ($data['expires_in'] ?? 3600); + + return $this->accessToken; + } + + Log::error('Google Cloud: OAuth 토큰 발급 실패', ['response' => $response->body()]); + + return null; + } catch (\Exception $e) { + Log::error('Google Cloud: OAuth 토큰 발급 예외', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * GCS에 파일 업로드 + */ + public function uploadToStorage(string $localPath, string $objectName): ?string + { + $token = $this->getAccessToken(); + if (! $token) { + return null; + } + + $bucket = config('services.google.storage_bucket'); + if (! $bucket) { + Log::error('Google Cloud: Storage 버킷 설정 없음'); + + return null; + } + + try { + $fileContent = file_get_contents($localPath); + $mimeType = mime_content_type($localPath) ?: 'audio/webm'; + + $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/'. + urlencode($bucket).'/o?uploadType=media&name='. + urlencode($objectName); + + $response = Http::withToken($token) + ->withHeaders(['Content-Type' => $mimeType]) + ->withBody($fileContent, $mimeType) + ->post($uploadUrl); + + if ($response->successful()) { + return 'gs://'.$bucket.'/'.$objectName; + } + + Log::error('Google Cloud: Storage 업로드 실패', ['response' => $response->body()]); + + return null; + } catch (\Exception $e) { + Log::error('Google Cloud: Storage 업로드 예외', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Base64 오디오를 GCS에 업로드 + */ + public function uploadBase64Audio(string $base64Audio, string $objectName): ?string + { + // Base64 데이터 파싱 + $audioData = $base64Audio; + if (preg_match('/^data:audio\/\w+;base64,(.+)$/', $base64Audio, $matches)) { + $audioData = $matches[1]; + } + + // 임시 파일 생성 + $tempPath = storage_path('app/temp/'.uniqid('audio_').'.webm'); + $tempDir = dirname($tempPath); + + if (! is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + file_put_contents($tempPath, base64_decode($audioData)); + + // GCS 업로드 + $gcsUri = $this->uploadToStorage($tempPath, $objectName); + + // 임시 파일 삭제 + @unlink($tempPath); + + return $gcsUri; + } + + /** + * Speech-to-Text API 호출 + */ + public function speechToText(string $gcsUri, string $languageCode = 'ko-KR'): ?string + { + $token = $this->getAccessToken(); + if (! $token) { + return null; + } + + try { + // 긴 오디오는 비동기 처리 (LongRunningRecognize) + $response = Http::withToken($token) + ->post('https://speech.googleapis.com/v1/speech:longrunningrecognize', [ + 'config' => [ + 'encoding' => 'WEBM_OPUS', + 'sampleRateHertz' => 48000, + 'languageCode' => $languageCode, + 'enableAutomaticPunctuation' => true, + 'model' => 'latest_long', + ], + 'audio' => [ + 'uri' => $gcsUri, + ], + ]); + + if (! $response->successful()) { + Log::error('Google Cloud: STT 요청 실패', ['response' => $response->body()]); + + return null; + } + + $operation = $response->json(); + $operationName = $operation['name'] ?? null; + + if (! $operationName) { + Log::error('Google Cloud: STT 작업 이름 없음'); + + return null; + } + + // 작업 완료 대기 (폴링) + return $this->waitForSttOperation($operationName); + } catch (\Exception $e) { + Log::error('Google Cloud: STT 예외', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * STT 작업 완료 대기 + */ + private function waitForSttOperation(string $operationName, int $maxAttempts = 60): ?string + { + $token = $this->getAccessToken(); + if (! $token) { + return null; + } + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep(5); // 5초 대기 + + $response = Http::withToken($token) + ->get("https://speech.googleapis.com/v1/operations/{$operationName}"); + + if (! $response->successful()) { + continue; + } + + $result = $response->json(); + + if (isset($result['done']) && $result['done']) { + if (isset($result['error'])) { + Log::error('Google Cloud: STT 작업 실패', ['error' => $result['error']]); + + return null; + } + + // 결과 텍스트 추출 + $transcript = ''; + $results = $result['response']['results'] ?? []; + + foreach ($results as $res) { + $alternatives = $res['alternatives'] ?? []; + if (! empty($alternatives)) { + $transcript .= $alternatives[0]['transcript'] ?? ''; + } + } + + return $transcript; + } + } + + Log::error('Google Cloud: STT 작업 타임아웃'); + + return null; + } + + /** + * GCS 파일 삭제 + */ + public function deleteFromStorage(string $objectName): bool + { + $token = $this->getAccessToken(); + if (! $token) { + return false; + } + + $bucket = config('services.google.storage_bucket'); + if (! $bucket) { + return false; + } + + try { + $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/'. + urlencode($bucket).'/o/'.urlencode($objectName); + + $response = Http::withToken($token)->delete($deleteUrl); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('Google Cloud: Storage 삭제 예외', ['error' => $e->getMessage()]); + + return false; + } + } + + /** + * 서비스 사용 가능 여부 + */ + public function isAvailable(): bool + { + return $this->serviceAccount !== null && $this->getAccessToken() !== null; + } +} diff --git a/app/Services/MeetingLogService.php b/app/Services/MeetingLogService.php new file mode 100644 index 00000000..3f934f2f --- /dev/null +++ b/app/Services/MeetingLogService.php @@ -0,0 +1,249 @@ +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' => currentTenantId(), + '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') + ); + + $gcsUri = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName); + + if (! $gcsUri) { + throw new \Exception('오디오 파일 업로드 실패'); + } + + $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) + */ + private function generateSummary(string $transcript): ?string + { + $apiKey = config('services.claude.api_key'); + if (empty($apiKey)) { + Log::warning('Claude API 키 미설정'); + + return null; + } + + 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' => $this->buildSummaryPrompt($transcript), + ], + ], + ]); + + if ($response->successful()) { + $data = $response->json(); + + 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; + } +} diff --git a/config/services.php b/config/services.php index 5c605d71..64f13270 100644 --- a/config/services.php +++ b/config/services.php @@ -44,4 +44,9 @@ 'api_key' => env('CLAUDE_API_KEY'), ], + 'google' => [ + 'credentials_path' => env('GOOGLE_APPLICATION_CREDENTIALS'), + 'storage_bucket' => env('GOOGLE_STORAGE_BUCKET'), + ], + ]; diff --git a/resources/views/lab/ai/web-recording.blade.php b/resources/views/lab/ai/web-recording.blade.php index 51c21bdc..30d1df0e 100644 --- a/resources/views/lab/ai/web-recording.blade.php +++ b/resources/views/lab/ai/web-recording.blade.php @@ -4,59 +4,531 @@ @push('styles') @endpush @section('content') -
-
-
- - - -

웹 녹음 AI 요약

-

- 웹 브라우저에서 직접 녹음하고 AI가 자동으로 - 음성을 텍스트로 변환하여 요약본을 생성합니다. -

-
AI/Automation
-
+
+ {{-- 헤더 --}} +
+

웹 녹음 AI 요약

+

브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다

+
-
-
-

- - - + {{-- 녹음 섹션 --}} +
+
+ {{-- 타이머 --}} +
00:00
+ + {{-- 파형 (녹음 중에만 표시) --}} + + + {{-- 상태 표시 --}} +
+ 대기 중 +
+ + {{-- 녹음 버튼 --}} +
+ +
+ + {{-- 안내 텍스트 --}} +

+ 버튼을 클릭하여 녹음을 시작하세요 +

+
+
+ + {{-- 처리 중 상태 (숨김) --}} + + + {{-- 결과 섹션 (숨김) --}} + @endsection + +@push('scripts') + + +{{-- Markdown 파서 --}} + +@endpush diff --git a/resources/views/lab/ai/web-recording/partials/list.blade.php b/resources/views/lab/ai/web-recording/partials/list.blade.php new file mode 100644 index 00000000..7c918073 --- /dev/null +++ b/resources/views/lab/ai/web-recording/partials/list.blade.php @@ -0,0 +1,60 @@ +{{-- 회의록 목록 (HTMX partial) --}} +@forelse($meetings as $meeting) +
+
+
+

{{ $meeting->title }}

+
+ {{ $meeting->user?->name ?? '알 수 없음' }} + | + {{ $meeting->created_at->format('Y-m-d H:i') }} + @if($meeting->duration_seconds) + | + {{ $meeting->formatted_duration }} + @endif +
+
+
+ @php + $statusClass = match($meeting->status) { + 'PENDING' => 'bg-yellow-100 text-yellow-700', + 'PROCESSING' => 'bg-blue-100 text-blue-700', + 'COMPLETED' => 'bg-green-100 text-green-700', + 'FAILED' => 'bg-red-100 text-red-700', + default => 'bg-gray-100 text-gray-700', + }; + @endphp + + {{ $meeting->status_label }} + + +
+
+ @if($meeting->transcript_text) +

+ {{ Str::limit($meeting->transcript_text, 100) }} +

+ @endif +
+@empty +
+ + + +

저장된 회의록이 없습니다.

+

새 녹음을 시작해보세요!

+
+@endforelse + +{{-- 페이지네이션 --}} +@if($meetings->hasPages()) +
+ {{ $meetings->links() }} +
+@endif diff --git a/resources/views/lab/ai/web-recording/partials/summary.blade.php b/resources/views/lab/ai/web-recording/partials/summary.blade.php new file mode 100644 index 00000000..cf9b77bb --- /dev/null +++ b/resources/views/lab/ai/web-recording/partials/summary.blade.php @@ -0,0 +1,98 @@ +{{-- 회의록 요약 결과 (HTMX partial) --}} +@if($meeting->isProcessing()) +
+
+

회의록을 생성하고 있습니다...

+

음성 인식 및 AI 요약 중

+
+@elseif($meeting->isCompleted()) +
+ {{-- 제목 편집 --}} +
+ + +
+ + {{-- 메타 정보 --}} +
+ 작성자: {{ $meeting->user?->name ?? '알 수 없음' }} + | + 녹음 시간: {{ $meeting->formatted_duration }} + | + {{ $meeting->created_at->format('Y-m-d H:i') }} +
+ + {{-- 음성 인식 결과 --}} +
+
+ 음성 인식 결과 + + + +
+
+
+

{{ $meeting->transcript_text ?: '음성 인식 결과가 없습니다.' }}

+
+
+
+ + {{-- AI 요약 결과 --}} +
+
+ AI 요약 + + + +
+
+
+ {!! nl2br(e($meeting->summary_text ?: 'AI 요약 결과가 없습니다.')) !!} +
+
+
+
+@else +
+ + + +

회의록 처리에 실패했습니다.

+

다시 시도해주세요.

+
+@endif + + diff --git a/routes/api.php b/routes/api.php index 82e8a742..dc702fb1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -17,6 +17,7 @@ use App\Http\Controllers\Api\Admin\RolePermissionController; use App\Http\Controllers\Api\Admin\TenantController; use App\Http\Controllers\Api\Admin\ItemFieldController; +use App\Http\Controllers\Api\Admin\MeetingLogController; use App\Http\Controllers\Api\Admin\UserController; use Illuminate\Support\Facades\Route; @@ -560,3 +561,38 @@ Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store'); Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy'); }); + +/* +|-------------------------------------------------------------------------- +| 웹 녹음 AI 요약 API +|-------------------------------------------------------------------------- +| +| Lab > AI > 웹 녹음 AI 요약 기능 +| Google STT + Claude API를 사용한 회의록 생성 +| +*/ +Route::middleware(['web', 'auth'])->prefix('meeting-logs')->name('api.admin.meeting-logs.')->group(function () { + // 목록 조회 (HTMX 지원) + Route::get('/', [MeetingLogController::class, 'index'])->name('index'); + + // 회의록 생성 (녹음 시작) + Route::post('/', [MeetingLogController::class, 'store'])->name('store'); + + // 상세 조회 + Route::get('/{id}', [MeetingLogController::class, 'show'])->name('show'); + + // 오디오 업로드 및 처리 + Route::post('/{id}/process', [MeetingLogController::class, 'processAudio'])->name('process'); + + // 제목 수정 + Route::put('/{id}/title', [MeetingLogController::class, 'updateTitle'])->name('update-title'); + + // 삭제 + Route::delete('/{id}', [MeetingLogController::class, 'destroy'])->name('destroy'); + + // 처리 상태 확인 (폴링용) + Route::get('/{id}/status', [MeetingLogController::class, 'status'])->name('status'); + + // 요약 결과 조회 (HTMX 지원) + Route::get('/{id}/summary', [MeetingLogController::class, 'summary'])->name('summary'); +});