feat: 웹 녹음 AI 요약 기능 구현

- MeetingLog 모델 (BelongsToTenant, SoftDeletes)
- GoogleCloudService (GCS 업로드, STT API)
- MeetingLogService (Claude API 요약)
- MeetingLogController (HTMX/JSON 듀얼 응답)
- 순수 Tailwind CSS UI 구현
- API 라우트 8개 엔드포인트 등록
This commit is contained in:
2025-12-16 15:07:56 +09:00
parent 22f07069e0
commit 331eaebf86
9 changed files with 1606 additions and 43 deletions

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\MeetingLog;
use App\Services\MeetingLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* 회의록 API 컨트롤러 (웹 녹음 AI 요약)
*/
class MeetingLogController extends Controller
{
public function __construct(
private readonly MeetingLogService $meetingLogService
) {}
/**
* 회의록 목록 (HTMX용)
*/
public function index(Request $request): View|JsonResponse
{
$params = $request->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,
],
]);
}
}

119
app/Models/MeetingLog.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 회의록 모델 (웹 녹음 AI 요약)
*
* admin_meeting_logs 테이블을 사용합니다.
*/
class MeetingLog extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'admin_meeting_logs';
protected $fillable = [
'tenant_id',
'user_id',
'title',
'audio_file_path',
'audio_gcs_uri',
'transcript_text',
'summary_text',
'status',
'duration_seconds',
'file_expiry_date',
];
protected $casts = [
'duration_seconds' => '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;
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Google Cloud 서비스 (Storage, Speech-to-Text)
*/
class GoogleCloudService
{
private ?array $serviceAccount = null;
private ?string $accessToken = null;
private ?int $tokenExpiry = null;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Services;
use App\Models\MeetingLog;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 회의록 서비스 (웹 녹음 AI 요약)
*/
class MeetingLogService
{
public function __construct(
private GoogleCloudService $googleCloudService
) {}
/**
* 회의록 목록 조회
*/
public function getList(array $params = []): LengthAwarePaginator
{
$query = MeetingLog::query()
->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 <<<PROMPT
다음은 회의 녹음을 텍스트로 변환한 내용입니다. 이 내용을 바탕으로 구조화된 회의록을 작성해주세요.
## 요청사항:
1. **회의 요약**: 핵심 내용을 3-5문장으로 요약
2. **주요 논의 사항**: 논의된 주요 주제들을 항목별로 정리
3. **결정 사항**: 회의에서 결정된 내용들
4. **액션 아이템**: 후속 조치가 필요한 항목들 (담당자, 기한 등이 언급되었다면 포함)
5. **참고 사항**: 기타 중요한 내용
## 회의 녹취록:
{$transcript}
## 회의록:
PROMPT;
}
/**
* 회의록 삭제
*/
public function delete(MeetingLog $meeting): bool
{
// GCS 파일 삭제
if ($meeting->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;
}
}

View File

@@ -44,4 +44,9 @@
'api_key' => env('CLAUDE_API_KEY'), 'api_key' => env('CLAUDE_API_KEY'),
], ],
'google' => [
'credentials_path' => env('GOOGLE_APPLICATION_CREDENTIALS'),
'storage_bucket' => env('GOOGLE_STORAGE_BUCKET'),
],
]; ];

View File

@@ -4,59 +4,531 @@
@push('styles') @push('styles')
<style> <style>
.placeholder-container { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } .recording-container {
.placeholder-icon { width: 5rem; height: 5rem; margin-bottom: 2rem; opacity: 0.6; color: #7c3aed; } max-width: 900px;
.placeholder-title { font-size: 2rem; font-weight: 700; color: #7c3aed; margin-bottom: 1rem; } margin: 0 auto;
.placeholder-subtitle { font-size: 1.25rem; color: #64748b; max-width: 500px; text-align: center; line-height: 1.8; } }
.placeholder-badge { margin-top: 2rem; padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: white; border-radius: 9999px; font-weight: 600; font-size: 0.875rem; }
.feature-icon { width: 1.25rem; height: 1.25rem; color: #7c3aed; } .record-button {
width: 100px;
height: 100px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.record-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.record-button.recording {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
animation: pulse 1.5s ease-in-out infinite;
}
.record-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); }
50% { box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8); }
}
.timer {
font-size: 2.5rem;
font-weight: bold;
font-family: 'Courier New', monospace;
min-height: 50px;
color: #374151;
}
.timer.active { color: #f5576c; }
.waveform {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
}
.waveform-bar {
width: 4px;
background: #667eea;
border-radius: 2px;
animation: wave 0.5s ease-in-out infinite;
}
@keyframes wave {
0%, 100% { height: 10px; }
50% { height: 40px; }
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.status-waiting { background: #f1f5f9; color: #64748b; }
.status-recording { background: #fee2e2; color: #dc2626; }
.status-processing { background: #dbeafe; color: #1d4ed8; }
.status-completed { background: #dcfce7; color: #16a34a; }
.status-error { background: #fee2e2; color: #dc2626; }
.meeting-card {
transition: all 0.2s ease;
cursor: pointer;
}
.meeting-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.accordion-header {
cursor: pointer;
user-select: none;
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-content.open {
max-height: 500px;
}
.accordion-icon {
transition: transform 0.3s ease;
}
.accordion-icon.open {
transform: rotate(180deg);
}
</style> </style>
@endpush @endpush
@section('content') @section('content')
<div class="min-h-screen bg-gradient-to-br from-violet-50 to-purple-100"> <div class="recording-container">
<div class="container mx-auto px-4 py-12"> {{-- 헤더 --}}
<div class="placeholder-container"> <div class="text-center mb-8">
<svg class="placeholder-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <h1 class="text-2xl font-bold text-gray-800 mb-2"> 녹음 AI 요약</h1>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /> <p class="text-gray-500">브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다</p>
</svg> </div>
<h1 class="placeholder-title"> 녹음 AI 요약</h1>
<p class="placeholder-subtitle">
브라우저에서 직접 녹음하고 AI가 자동으로
음성을 텍스트로 변환하여 요약본을 생성합니다.
</p>
<div class="placeholder-badge">AI/Automation</div>
</div>
<div class="max-w-4xl mx-auto mt-12"> {{-- 녹음 섹션 --}}
<div class="bg-white rounded-2xl shadow-lg p-8"> <div class="bg-white rounded-xl shadow-md mb-8 p-8">
<h2 class="text-xl font-bold text-gray-800 mb-6 flex items-center"> <div class="flex flex-col items-center text-center">
<svg class="feature-icon mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {{-- 타이머 --}}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <div class="timer" id="timer">00:00</div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
{{-- 파형 (녹음 중에만 표시) --}}
<div class="waveform hidden mt-4" id="waveform">
@for($i = 0; $i < 20; $i++)
<div class="waveform-bar" style="animation-delay: {{ $i * 0.05 }}s"></div>
@endfor
</div>
{{-- 상태 표시 --}}
<div class="my-4">
<span class="status-badge status-waiting" id="statusBadge">대기 </span>
</div>
{{-- 녹음 버튼 --}}
<div class="flex gap-4 items-center">
<button class="record-button" id="recordBtn" onclick="toggleRecording()">
<svg id="micIcon" class="w-10 h-10 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg> </svg>
예정 기능 <svg id="stopIcon" class="w-10 h-10 mx-auto hidden" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</button>
</div>
{{-- 안내 텍스트 --}}
<p class="text-sm text-gray-500 mt-4" id="helpText">
버튼을 클릭하여 녹음을 시작하세요
</p>
</div>
</div>
{{-- 처리 상태 (숨김) --}}
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
<div class="flex flex-col items-center text-center">
<div class="spinner"></div>
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 ...</p>
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
<div class="h-full bg-blue-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
</div>
</div>
</div>
{{-- 결과 섹션 (숨김) --}}
<div class="hidden" id="resultSection">
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
AI 요약 결과
</h2> </h2>
<div class="grid md:grid-cols-2 gap-6"> <button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="newRecording()"> 녹음</button>
<div class="p-4 bg-violet-50 rounded-lg"> </div>
<h3 class="font-semibold text-violet-800 mb-2">녹음 기능</h3>
<ul class="text-sm text-gray-600 space-y-1"> {{-- 제목 입력 --}}
<li> 브라우저 실시간 녹음</li> <div class="mb-4">
<li> 일시정지/재개 지원</li> <input type="text" id="meetingTitle" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="회의록 제목을 입력하세요" />
<li> 녹음 파일 다운로드</li> </div>
</ul>
</div> {{-- 요약 내용 --}}
<div class="p-4 bg-blue-50 rounded-lg"> <div class="bg-gray-50 rounded-lg p-4 mb-4">
<h3 class="font-semibold text-blue-800 mb-2">AI 처리</h3> <h3 class="font-semibold text-gray-800 mb-2">요약</h3>
<ul class="text-sm text-gray-600 space-y-1"> <div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
<li> Speech-to-Text 변환</li> </div>
<li> 핵심 내용 자동 요약</li>
<li> 키워드 추출</li> {{-- 원본 텍스트 (아코디언) --}}
</ul> <div class="bg-gray-50 rounded-lg overflow-hidden">
</div> <div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div> </div>
<div class="accordion-content">
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
</div>
</div>
</div>
</div>
{{-- 최근 회의록 목록 --}}
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
최근 회의록
</h2>
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
<div class="flex justify-center py-8">
<div class="spinner"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@endsection @endsection
@push('scripts')
<script>
let mediaRecorder = null;
let audioChunks = [];
let timerInterval = null;
let startTime = null;
let currentMeetingId = null;
// 아코디언 토글
function toggleAccordion(header) {
const content = header.nextElementSibling;
const icon = header.querySelector('.accordion-icon');
content.classList.toggle('open');
icon.classList.toggle('open');
}
// 녹음 토글
async function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
await startRecording();
}
}
// 녹음 시작
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
await processAudio(audioBlob);
stream.getTracks().forEach(track => track.stop());
};
// 회의록 생성
const response = await fetch('{{ route("api.admin.meeting-logs.store") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ title: '무제 회의록' })
});
const result = await response.json();
if (result.success) {
currentMeetingId = result.data.id;
}
mediaRecorder.start(1000);
startTime = Date.now();
updateUI('recording');
startTimer();
} catch (error) {
console.error('녹음 시작 실패:', error);
showToast('마이크 접근 권한이 필요합니다.', 'error');
}
}
// 녹음 중지
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
stopTimer();
updateUI('processing');
}
}
// 오디오 처리
async function processAudio(audioBlob) {
const duration = Math.floor((Date.now() - startTime) / 1000);
// Base64 변환
const reader = new FileReader();
reader.onloadend = async () => {
const base64Audio = reader.result;
try {
updateProgress(10);
document.getElementById('processingStatus').textContent = '서버에 업로드 중...';
const response = await fetch(`/api/meeting-logs/${currentMeetingId}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
audio: base64Audio,
duration: duration
})
});
updateProgress(100);
const result = await response.json();
if (result.success) {
showResult(result.data);
} else {
throw new Error(result.message || '처리 실패');
}
} catch (error) {
console.error('처리 실패:', error);
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
updateUI('ready');
}
};
reader.readAsDataURL(audioBlob);
}
// 진행률 업데이트
function updateProgress(percent) {
document.getElementById('progressBar').style.width = percent + '%';
}
// 결과 표시
function showResult(meeting) {
document.getElementById('meetingTitle').value = meeting.title || '';
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
updateUI('result');
// 목록 새로고침
htmx.trigger('#meetingList', 'load');
}
// UI 업데이트
function updateUI(state) {
const recordBtn = document.getElementById('recordBtn');
const micIcon = document.getElementById('micIcon');
const stopIcon = document.getElementById('stopIcon');
const waveform = document.getElementById('waveform');
const statusBadge = document.getElementById('statusBadge');
const helpText = document.getElementById('helpText');
const processingCard = document.getElementById('processingCard');
const resultSection = document.getElementById('resultSection');
switch (state) {
case 'recording':
recordBtn.classList.add('recording');
micIcon.classList.add('hidden');
stopIcon.classList.remove('hidden');
waveform.classList.remove('hidden');
statusBadge.textContent = '녹음 중';
statusBadge.className = 'status-badge status-recording';
helpText.textContent = '버튼을 클릭하여 녹음을 종료하세요';
processingCard.classList.add('hidden');
resultSection.classList.add('hidden');
break;
case 'processing':
recordBtn.classList.remove('recording');
recordBtn.disabled = true;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '처리 중';
statusBadge.className = 'status-badge status-processing';
helpText.textContent = 'AI가 회의록을 작성하고 있습니다...';
processingCard.classList.remove('hidden');
resultSection.classList.add('hidden');
updateProgress(0);
break;
case 'result':
recordBtn.classList.remove('recording');
recordBtn.disabled = false;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '완료';
statusBadge.className = 'status-badge status-completed';
helpText.textContent = '버튼을 클릭하여 새로운 녹음을 시작하세요';
processingCard.classList.add('hidden');
resultSection.classList.remove('hidden');
break;
default: // ready
recordBtn.classList.remove('recording');
recordBtn.disabled = false;
micIcon.classList.remove('hidden');
stopIcon.classList.add('hidden');
waveform.classList.add('hidden');
statusBadge.textContent = '대기 중';
statusBadge.className = 'status-badge status-waiting';
helpText.textContent = '버튼을 클릭하여 녹음을 시작하세요';
processingCard.classList.add('hidden');
resultSection.classList.add('hidden');
}
}
// 타이머 시작
function startTimer() {
const timerEl = document.getElementById('timer');
timerEl.classList.add('active');
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
const seconds = (elapsed % 60).toString().padStart(2, '0');
timerEl.textContent = `${minutes}:${seconds}`;
}, 1000);
}
// 타이머 중지
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
document.getElementById('timer').classList.remove('active');
}
// 새 녹음
function newRecording() {
currentMeetingId = null;
document.getElementById('timer').textContent = '00:00';
updateUI('ready');
}
// 회의록 삭제
async function deleteMeeting(id) {
showDeleteConfirm('이 회의록', async () => {
try {
const response = await fetch(`/api/meeting-logs/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
showToast('삭제되었습니다.', 'success');
htmx.trigger('#meetingList', 'load');
} else {
showToast(result.message || '삭제 실패', 'error');
}
} catch (error) {
console.error('삭제 실패:', error);
showToast('삭제 중 오류가 발생했습니다.', 'error');
}
});
}
// 회의록 상세 보기
async function viewMeeting(id) {
try {
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
});
const result = await response.json();
if (result.success) {
document.getElementById('meetingTitle').value = result.data.transcript ? '회의록' : '';
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
updateUI('result');
}
} catch (error) {
console.error('조회 실패:', error);
showToast('조회 중 오류가 발생했습니다.', 'error');
}
}
</script>
{{-- Markdown 파서 --}}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
@endpush

View File

@@ -0,0 +1,60 @@
{{-- 회의록 목록 (HTMX partial) --}}
@forelse($meetings as $meeting)
<div class="meeting-card bg-white border border-gray-200 rounded-lg p-4 mb-3 hover:shadow-md"
onclick="viewMeeting({{ $meeting->id }})">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-semibold text-gray-800">{{ $meeting->title }}</h3>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
<span>{{ $meeting->user?->name ?? '알 수 없음' }}</span>
<span>|</span>
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
@if($meeting->duration_seconds)
<span>|</span>
<span>{{ $meeting->formatted_duration }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2">
@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
<span class="text-xs px-2 py-1 rounded-full {{ $statusClass }}">
{{ $meeting->status_label }}
</span>
<button class="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
onclick="event.stopPropagation(); deleteMeeting({{ $meeting->id }})">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
@if($meeting->transcript_text)
<p class="text-sm text-gray-600 mt-2 line-clamp-2">
{{ Str::limit($meeting->transcript_text, 100) }}
</p>
@endif
</div>
@empty
<div class="text-center py-12 text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<p>저장된 회의록이 없습니다.</p>
<p class="text-sm mt-1"> 녹음을 시작해보세요!</p>
</div>
@endforelse
{{-- 페이지네이션 --}}
@if($meetings->hasPages())
<div class="mt-4 flex justify-center">
{{ $meetings->links() }}
</div>
@endif

View File

@@ -0,0 +1,98 @@
{{-- 회의록 요약 결과 (HTMX partial) --}}
@if($meeting->isProcessing())
<div class="text-center py-8">
<div class="spinner mx-auto"></div>
<p class="mt-4 text-gray-600">회의록을 생성하고 있습니다...</p>
<p class="text-sm text-gray-400">음성 인식 AI 요약 </p>
</div>
@elseif($meeting->isCompleted())
<div class="space-y-6">
{{-- 제목 편집 --}}
<div class="flex items-center gap-2">
<input type="text"
id="meeting-title-{{ $meeting->id }}"
value="{{ $meeting->title }}"
class="flex-1 px-4 py-3 border border-gray-300 rounded-lg font-semibold text-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="회의록 제목">
<button class="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
onclick="updateMeetingTitle({{ $meeting->id }})">
저장
</button>
</div>
{{-- 메타 정보 --}}
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>작성자: {{ $meeting->user?->name ?? '알 수 없음' }}</span>
<span>|</span>
<span>녹음 시간: {{ $meeting->formatted_duration }}</span>
<span>|</span>
<span>{{ $meeting->created_at->format('Y-m-d H:i') }}</span>
</div>
{{-- 음성 인식 결과 --}}
<div class="bg-gray-50 rounded-lg overflow-hidden">
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
<span class="font-medium text-gray-800">음성 인식 결과</span>
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div class="accordion-content open">
<div class="px-4 pb-4">
<p class="whitespace-pre-wrap text-gray-600 text-sm">{{ $meeting->transcript_text ?: '음성 인식 결과가 없습니다.' }}</p>
</div>
</div>
</div>
{{-- AI 요약 결과 --}}
<div class="bg-gray-50 rounded-lg overflow-hidden">
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
<span class="font-medium text-gray-800">AI 요약</span>
<svg class="accordion-icon w-5 h-5 text-gray-500 open" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div class="accordion-content open">
<div class="px-4 pb-4 prose max-w-none text-sm">
{!! nl2br(e($meeting->summary_text ?: 'AI 요약 결과가 없습니다.')) !!}
</div>
</div>
</div>
</div>
@else
<div class="text-center py-8 text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p>회의록 처리에 실패했습니다.</p>
<p class="text-sm mt-1">다시 시도해주세요.</p>
</div>
@endif
<script>
async function updateMeetingTitle(id) {
const title = document.getElementById('meeting-title-' + id).value;
try {
const response = await fetch(`/api/meeting-logs/${id}/title`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ title: title })
});
const result = await response.json();
if (result.success) {
showToast('제목이 저장되었습니다.', 'success');
htmx.trigger('#meetingList', 'load');
} else {
showToast(result.message || '저장 실패', 'error');
}
} catch (error) {
console.error('저장 실패:', error);
showToast('저장 중 오류가 발생했습니다.', 'error');
}
}
</script>

View File

@@ -17,6 +17,7 @@
use App\Http\Controllers\Api\Admin\RolePermissionController; use App\Http\Controllers\Api\Admin\RolePermissionController;
use App\Http\Controllers\Api\Admin\TenantController; use App\Http\Controllers\Api\Admin\TenantController;
use App\Http\Controllers\Api\Admin\ItemFieldController; use App\Http\Controllers\Api\Admin\ItemFieldController;
use App\Http\Controllers\Api\Admin\MeetingLogController;
use App\Http\Controllers\Api\Admin\UserController; use App\Http\Controllers\Api\Admin\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -560,3 +561,38 @@
Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store'); Route::post('/', [\App\Http\Controllers\Api\BizCertController::class, 'store'])->name('store');
Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy'); 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');
});