Files
sam-manage/app/Services/MeetingLogService.php
김보곤 d121a319b3 feat:GCS 업로드/STT 사용량 토큰 기록 추가
- AiTokenHelper: saveGcsStorageUsage(), saveSttUsage() 메서드 추가
- GoogleCloudService: uploadToStorage 반환값 배열로 변경 (uri + size)
- AiVoiceRecordingService: GCS/STT 각각 토큰 사용량 기록
- MeetingLogService: uploadToStorage 반환값 변경 대응

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:45:50 +09:00

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(),
];
}
}
}