Files
sam-manage/app/Services/AiVoiceRecordingService.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

497 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Helpers\AiTokenHelper;
use App\Models\AiVoiceRecording;
use App\Models\System\AiConfig;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AiVoiceRecordingService
{
public function __construct(
private GoogleCloudService $googleCloudService
) {}
/**
* 목록 조회
*/
public function getList(array $params = []): LengthAwarePaginator
{
$query = AiVoiceRecording::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('analysis_text', 'like', "%{$search}%");
});
}
$perPage = $params['per_page'] ?? 10;
return $query->paginate($perPage);
}
/**
* 상세 조회
*/
public function getById(int $id): ?AiVoiceRecording
{
return AiVoiceRecording::with('user:id,name')->find($id);
}
/**
* 새 녹음 레코드 생성
*/
public function create(array $data): AiVoiceRecording
{
return AiVoiceRecording::create([
'tenant_id' => session('selected_tenant_id'),
'user_id' => Auth::id(),
'title' => $data['title'] ?? '무제 음성녹음',
'interview_template_id' => $data['interview_template_id'] ?? null,
'status' => AiVoiceRecording::STATUS_PENDING,
'file_expiry_date' => now()->addDays(7),
]);
}
/**
* Base64 오디오 업로드 + 처리 파이프라인
*/
public function processAudio(AiVoiceRecording $recording, string $audioBase64, int $durationSeconds): array
{
try {
$recording->update([
'status' => AiVoiceRecording::STATUS_PROCESSING,
'duration_seconds' => $durationSeconds,
]);
// 1. GCS에 오디오 업로드
$objectName = sprintf(
'voice-recordings/%d/%d/%s.webm',
$recording->tenant_id,
$recording->id,
now()->format('YmdHis')
);
$uploadResult = $this->googleCloudService->uploadBase64Audio($audioBase64, $objectName);
if (! $uploadResult) {
throw new \Exception('오디오 파일 업로드 실패');
}
$gcsUri = $uploadResult['uri'];
$fileSize = $uploadResult['size'] ?? 0;
$recording->update([
'audio_file_path' => $objectName,
'audio_gcs_uri' => $gcsUri,
]);
// GCS 업로드 토큰 사용량 기록
AiTokenHelper::saveGcsStorageUsage('AI음성녹음-GCS저장', $fileSize);
// 2. Speech-to-Text 변환
Log::info('AiVoiceRecording STT 시작', ['recording_id' => $recording->id, 'gcs_uri' => $gcsUri]);
$transcript = $this->googleCloudService->speechToText($gcsUri);
Log::info('AiVoiceRecording STT 결과', ['recording_id' => $recording->id, 'transcript_length' => strlen($transcript ?? ''), 'transcript_preview' => mb_substr($transcript ?? '', 0, 100)]);
if (! $transcript) {
throw new \Exception('음성 인식 실패: STT 결과가 비어있습니다 (transcript=' . var_export($transcript, true) . ')');
}
// STT 토큰 사용량 기록
AiTokenHelper::saveSttUsage('AI음성녹음-STT변환', $durationSeconds);
$recording->update(['transcript_text' => $transcript]);
// 3. Gemini AI 분석
$analysis = $this->analyzeWithGemini($transcript);
$recording->update([
'analysis_text' => $analysis,
'status' => AiVoiceRecording::STATUS_COMPLETED,
]);
return [
'ok' => true,
'recording' => $recording->fresh(),
];
} catch (\Exception $e) {
Log::error('AiVoiceRecording 처리 실패', [
'recording_id' => $recording->id,
'error' => $e->getMessage(),
]);
$recording->update(['status' => AiVoiceRecording::STATUS_FAILED]);
return [
'ok' => false,
'error' => $e->getMessage(),
];
}
}
/**
* 파일 업로드 처리
*/
public function processUploadedFile(AiVoiceRecording $recording, UploadedFile $file): array
{
try {
$recording->update(['status' => AiVoiceRecording::STATUS_PROCESSING]);
// 임시 저장
$tempPath = $file->store('temp', 'local');
$fullPath = storage_path('app/' . $tempPath);
// 파일 크기로 대략적인 재생 시간 추정 (12KB/초 기준)
$fileSize = $file->getSize();
$estimatedDuration = max(1, intval($fileSize / 12000));
$recording->update(['duration_seconds' => $estimatedDuration]);
// 1. GCS에 오디오 업로드
$extension = $file->getClientOriginalExtension() ?: 'webm';
$objectName = sprintf(
'voice-recordings/%d/%d/%s.%s',
$recording->tenant_id,
$recording->id,
now()->format('YmdHis'),
$extension
);
$uploadResult = $this->googleCloudService->uploadToStorage($fullPath, $objectName);
if (! $uploadResult) {
@unlink($fullPath);
throw new \Exception('오디오 파일 업로드 실패');
}
$gcsUri = $uploadResult['uri'];
$uploadedSize = $uploadResult['size'] ?? $fileSize;
$recording->update([
'audio_file_path' => $objectName,
'audio_gcs_uri' => $gcsUri,
]);
// GCS 업로드 토큰 사용량 기록
AiTokenHelper::saveGcsStorageUsage('AI음성녹음-GCS저장', $uploadedSize);
// 2. Speech-to-Text 변환
$transcript = $this->googleCloudService->speechToText($gcsUri);
// 임시 파일 삭제
@unlink($fullPath);
if (! $transcript) {
throw new \Exception('음성 인식 실패');
}
// STT 토큰 사용량 기록
AiTokenHelper::saveSttUsage('AI음성녹음-STT변환', $estimatedDuration);
$recording->update(['transcript_text' => $transcript]);
// 3. Gemini AI 분석
$analysis = $this->analyzeWithGemini($transcript);
$recording->update([
'analysis_text' => $analysis,
'status' => AiVoiceRecording::STATUS_COMPLETED,
]);
return [
'ok' => true,
'recording' => $recording->fresh(),
];
} catch (\Exception $e) {
Log::error('AiVoiceRecording 파일 처리 실패', [
'recording_id' => $recording->id,
'error' => $e->getMessage(),
]);
$recording->update(['status' => AiVoiceRecording::STATUS_FAILED]);
return [
'ok' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Gemini API로 영업 시나리오 분석
*/
private function analyzeWithGemini(string $transcript): ?string
{
$config = AiConfig::getActiveGemini();
if (! $config) {
Log::warning('Gemini API 설정이 없습니다.');
return null;
}
$prompt = $this->buildAnalysisPrompt($transcript);
if ($config->isVertexAi()) {
return $this->callVertexAiApi($config, $prompt);
}
return $this->callGoogleAiStudioApi($config, $prompt);
}
/**
* Google AI Studio API 호출
*/
private function callGoogleAiStudioApi(AiConfig $config, string $prompt): ?string
{
$model = $config->model;
$apiKey = $config->api_key;
$baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
return $this->callGeminiApi($url, $prompt, [
'Content-Type' => 'application/json',
], false);
}
/**
* Vertex AI API 호출
*/
private function callVertexAiApi(AiConfig $config, string $prompt): ?string
{
$model = $config->model;
$projectId = $config->getProjectId();
$region = $config->getRegion();
if (! $projectId) {
Log::error('Vertex AI 프로젝트 ID가 설정되지 않았습니다.');
return null;
}
$accessToken = $this->getAccessToken($config);
if (! $accessToken) {
Log::error('Google Cloud 인증 실패');
return null;
}
$url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent";
return $this->callGeminiApi($url, $prompt, [
'Authorization' => 'Bearer ' . $accessToken,
'Content-Type' => 'application/json',
], true);
}
/**
* Gemini API 공통 호출 로직
*/
private function callGeminiApi(string $url, string $prompt, array $headers, bool $isVertexAi = false): ?string
{
$content = [
'parts' => [
['text' => $prompt],
],
];
if ($isVertexAi) {
$content['role'] = 'user';
}
try {
$response = Http::timeout(120)
->withHeaders($headers)
->post($url, [
'contents' => [$content],
'generationConfig' => [
'temperature' => 0.3,
'topK' => 40,
'topP' => 0.95,
'maxOutputTokens' => 4096,
],
]);
if (! $response->successful()) {
Log::error('Gemini API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$result = $response->json();
// 토큰 사용량 저장
AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? 'gemini', 'AI음성녹음분석');
return $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
} catch (\Exception $e) {
Log::error('Gemini API 예외', ['error' => $e->getMessage()]);
return null;
}
}
/**
* 서비스 계정으로 OAuth2 액세스 토큰 가져오기
*/
private function getAccessToken(AiConfig $config): ?string
{
$configuredPath = $config->getServiceAccountPath();
$possiblePaths = array_filter([
$configuredPath,
'/var/www/sales/apikey/google_service_account.json',
storage_path('app/google_service_account.json'),
]);
$serviceAccountPath = null;
foreach ($possiblePaths as $path) {
if ($path && file_exists($path)) {
$serviceAccountPath = $path;
break;
}
}
if (! $serviceAccountPath) {
Log::error('Service account file not found', ['tried_paths' => $possiblePaths]);
return null;
}
$serviceAccount = json_decode(file_get_contents($serviceAccountPath), true);
if (! $serviceAccount) {
Log::error('Service account JSON parse failed');
return null;
}
$now = time();
$jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$jwtClaim = $this->base64UrlEncode(json_encode([
'iss' => $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($serviceAccount['private_key']);
if (! $privateKey) {
Log::error('Failed to load private key');
return null;
}
openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature);
try {
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]);
if ($response->successful()) {
return $response->json()['access_token'] ?? null;
}
Log::error('OAuth token request failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
} catch (\Exception $e) {
Log::error('OAuth token request exception', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Base64 URL 인코딩
*/
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* 영업 시나리오 분석 프롬프트
*/
private function buildAnalysisPrompt(string $transcript): string
{
return <<<PROMPT
다음은 회의 또는 영업 통화 녹취록을 텍스트로 변환한 내용입니다. 이 내용을 분석하여 구조화된 피드백을 제공해주세요.
## 분석 요청사항:
### 1. 전체 요약
- 녹취록의 핵심 내용을 3-5문장으로 요약해주세요.
### 2. 영업 강점 분석
- 잘한 점, 효과적인 커뮤니케이션, 성공적인 전략 등을 구체적으로 분석해주세요.
### 3. 개선이 필요한 부분
- 놓친 기회, 개선할 수 있는 표현, 더 효과적인 접근 방법 등을 제안해주세요.
### 4. 고객 반응/니즈 파악
- 고객이 표현한 관심사, 우려사항, 요구사항을 정리해주세요.
- 숨겨진 니즈나 잠재적 기회를 파악해주세요.
### 5. 다음 단계 제안
- 후속 조치 사항을 구체적으로 제안해주세요.
- 우선순위와 시기를 포함해주세요.
## 녹취록:
{$transcript}
## 분석 결과:
PROMPT;
}
/**
* 삭제
*/
public function delete(AiVoiceRecording $recording): bool
{
if ($recording->audio_file_path) {
$this->googleCloudService->deleteFromStorage($recording->audio_file_path);
}
return $recording->delete();
}
/**
* 만료된 파일 정리 (Cron용)
*/
public function cleanupExpiredFiles(): int
{
$expired = AiVoiceRecording::where('file_expiry_date', '<=', now())
->whereNotNull('audio_file_path')
->get();
$count = 0;
foreach ($expired as $recording) {
if ($recording->audio_file_path) {
$this->googleCloudService->deleteFromStorage($recording->audio_file_path);
$recording->update([
'audio_file_path' => null,
'audio_gcs_uri' => null,
]);
$count++;
}
}
return $count;
}
}