- AiTokenHelper: saveGcsStorageUsage(), saveSttUsage() 메서드 추가 - GoogleCloudService: uploadToStorage 반환값 배열로 변경 (uri + size) - AiVoiceRecordingService: GCS/STT 각각 토큰 사용량 기록 - MeetingLogService: uploadToStorage 반환값 변경 대응 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
16 KiB
PHP
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;
|
|
}
|
|
}
|