- AiTokenHelper: saveGcsStorageUsage(), saveSttUsage() 메서드 추가 - GoogleCloudService: uploadToStorage 반환값 배열로 변경 (uri + size) - AiVoiceRecordingService: GCS/STT 각각 토큰 사용량 기록 - MeetingLogService: uploadToStorage 반환값 변경 대응 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
11 KiB
PHP
369 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Helpers\AiTokenHelper;
|
|
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' => session('selected_tenant_id'),
|
|
'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')
|
|
);
|
|
|
|
$uploadResult = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName);
|
|
|
|
if (! $uploadResult) {
|
|
throw new \Exception('오디오 파일 업로드 실패');
|
|
}
|
|
|
|
$gcsUri = $uploadResult['uri'];
|
|
|
|
$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)
|
|
*
|
|
* @param string $summaryType meeting|work-memo
|
|
*/
|
|
private function generateSummary(string $transcript, string $summaryType = 'meeting'): ?string
|
|
{
|
|
$apiKey = config('services.claude.api_key');
|
|
if (empty($apiKey)) {
|
|
Log::warning('Claude API 키 미설정');
|
|
|
|
return null;
|
|
}
|
|
|
|
$prompt = $summaryType === 'work-memo'
|
|
? $this->buildWorkMemoPrompt($transcript)
|
|
: $this->buildSummaryPrompt($transcript);
|
|
|
|
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' => $prompt,
|
|
],
|
|
],
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
// 토큰 사용량 저장
|
|
AiTokenHelper::saveClaudeUsage($data, 'claude-3-haiku-20240307', '회의록AI요약');
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 업무협의록 프롬프트 생성 (고객사 미팅용)
|
|
*/
|
|
private function buildWorkMemoPrompt(string $transcript): string
|
|
{
|
|
return <<<PROMPT
|
|
다음은 고객사와의 업무 협의 미팅 녹음을 텍스트로 변환한 내용입니다. 이 내용을 바탕으로 구조화된 업무협의록을 작성해주세요.
|
|
|
|
## 요청사항:
|
|
1. **협의 개요**: 미팅의 목적과 핵심 내용을 3-5문장으로 요약
|
|
2. **고객 요구사항**: 고객이 요청하거나 원하는 사항들을 구체적으로 정리
|
|
3. **합의 사항**: 양측이 합의한 내용들 (범위, 일정, 비용, 조건 등)
|
|
4. **미결 사항**: 추가 검토가 필요하거나 결정되지 않은 사항들
|
|
5. **후속 조치 (To-Do)**:
|
|
- 우리측 조치: 담당자, 기한, 세부 내용
|
|
- 고객측 조치: 담당자, 기한, 세부 내용
|
|
6. **특이사항/리스크**: 주의가 필요한 사항이나 잠재적 위험 요소
|
|
|
|
## 협의 녹취록:
|
|
{$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;
|
|
}
|
|
|
|
/**
|
|
* 업로드된 오디오 파일 처리 (회의록 AI 요약)
|
|
*
|
|
* @param string $summaryType meeting|work-memo
|
|
*/
|
|
public function processUploadedFile(MeetingLog $meeting, \Illuminate\Http\UploadedFile $file, string $summaryType = 'meeting'): array
|
|
{
|
|
try {
|
|
$meeting->update(['status' => MeetingLog::STATUS_PROCESSING]);
|
|
|
|
// 임시 저장
|
|
$tempPath = $file->store('temp', 'local');
|
|
$fullPath = storage_path('app/'.$tempPath);
|
|
|
|
// 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준)
|
|
$fileSize = $file->getSize();
|
|
$estimatedDuration = max(1, intval($fileSize / 12000));
|
|
$meeting->update(['duration_seconds' => $estimatedDuration]);
|
|
|
|
// 1. GCS에 오디오 업로드
|
|
$extension = $file->getClientOriginalExtension() ?: 'webm';
|
|
$objectName = sprintf(
|
|
'meetings/%d/%d/%s.%s',
|
|
$meeting->tenant_id,
|
|
$meeting->id,
|
|
now()->format('YmdHis'),
|
|
$extension
|
|
);
|
|
|
|
$uploadResult = $this->googleCloudService->uploadToStorage($fullPath, $objectName);
|
|
|
|
if (! $uploadResult) {
|
|
@unlink($fullPath);
|
|
throw new \Exception('오디오 파일 업로드 실패');
|
|
}
|
|
|
|
$gcsUri = $uploadResult['uri'];
|
|
|
|
$meeting->update([
|
|
'audio_file_path' => $objectName,
|
|
'audio_gcs_uri' => $gcsUri,
|
|
]);
|
|
|
|
// 2. Speech-to-Text 변환
|
|
$transcript = $this->googleCloudService->speechToText($gcsUri);
|
|
|
|
// 임시 파일 삭제
|
|
@unlink($fullPath);
|
|
|
|
if (! $transcript) {
|
|
throw new \Exception('음성 인식 실패');
|
|
}
|
|
|
|
$meeting->update(['transcript_text' => $transcript]);
|
|
|
|
// 3. AI 요약 생성 (summaryType에 따라 프롬프트 선택)
|
|
$summary = $this->generateSummary($transcript, $summaryType);
|
|
|
|
$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(),
|
|
];
|
|
}
|
|
}
|
|
}
|