- MeetingLog 모델 (BelongsToTenant, SoftDeletes) - GoogleCloudService (GCS 업로드, STT API) - MeetingLogService (Claude API 요약) - MeetingLogController (HTMX/JSON 듀얼 응답) - 순수 Tailwind CSS UI 구현 - API 라우트 8개 엔드포인트 등록
250 lines
6.9 KiB
PHP
250 lines
6.9 KiB
PHP
<?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;
|
|
}
|
|
}
|