Files
sam-manage/app/Services/MeetingLogService.php
hskwon 331eaebf86 feat: 웹 녹음 AI 요약 기능 구현
- MeetingLog 모델 (BelongsToTenant, SoftDeletes)
- GoogleCloudService (GCS 업로드, STT API)
- MeetingLogService (Claude API 요약)
- MeetingLogController (HTMX/JSON 듀얼 응답)
- 순수 Tailwind CSS UI 구현
- API 라우트 8개 엔드포인트 등록
2025-12-16 15:07:56 +09:00

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;
}
}