- GoogleCloudService에 speechToTextWithDiarization 메서드 추가
- Google STT V1 diarizationConfig 활성화로 자동 화자 구분
- MeetingMinuteService에 processDiarization 메서드 추가
- POST /{id}/diarize 엔드포인트 및 라우트 추가
- 프론트엔드에 '화자 분리' 버튼 추가 (RecordingControlBar)
- saveSegments 컨트롤러에 try-catch 에러 핸들링 추가
- 빈 텍스트 세그먼트 필터링 로직 추가 (서버/클라이언트 양쪽)
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\Juil\MeetingMinute;
|
|
use App\Models\Juil\MeetingMinuteSegment;
|
|
use App\Models\System\AiConfig;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class MeetingMinuteService
|
|
{
|
|
public function __construct(
|
|
private readonly GoogleCloudService $googleCloudService
|
|
) {}
|
|
|
|
public function getList(array $params): LengthAwarePaginator
|
|
{
|
|
$query = MeetingMinute::with('user:id,name')
|
|
->orderBy('meeting_date', 'desc')
|
|
->orderBy('id', 'desc');
|
|
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('title', 'like', "%{$search}%")
|
|
->orWhere('full_transcript', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
if (! empty($params['date_from'])) {
|
|
$query->where('meeting_date', '>=', $params['date_from']);
|
|
}
|
|
|
|
if (! empty($params['date_to'])) {
|
|
$query->where('meeting_date', '<=', $params['date_to']);
|
|
}
|
|
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
$perPage = (int) ($params['per_page'] ?? 12);
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
public function create(array $data): MeetingMinute
|
|
{
|
|
return MeetingMinute::create([
|
|
'tenant_id' => session('selected_tenant_id'),
|
|
'user_id' => Auth::id(),
|
|
'title' => $data['title'] ?? '무제 회의록',
|
|
'folder' => $data['folder'] ?? null,
|
|
'participants' => $data['participants'] ?? null,
|
|
'meeting_date' => $data['meeting_date'] ?? now()->toDateString(),
|
|
'meeting_time' => $data['meeting_time'] ?? now()->format('H:i'),
|
|
'status' => MeetingMinute::STATUS_DRAFT,
|
|
'stt_language' => $data['stt_language'] ?? 'ko-KR',
|
|
]);
|
|
}
|
|
|
|
public function update(MeetingMinute $meeting, array $data): MeetingMinute
|
|
{
|
|
$meeting->update(array_filter([
|
|
'title' => $data['title'] ?? null,
|
|
'folder' => array_key_exists('folder', $data) ? $data['folder'] : null,
|
|
'participants' => array_key_exists('participants', $data) ? $data['participants'] : null,
|
|
'meeting_date' => $data['meeting_date'] ?? null,
|
|
'meeting_time' => $data['meeting_time'] ?? null,
|
|
], fn ($v) => $v !== null));
|
|
|
|
return $meeting->fresh();
|
|
}
|
|
|
|
public function delete(MeetingMinute $meeting): bool
|
|
{
|
|
if ($meeting->audio_file_path) {
|
|
$this->googleCloudService->deleteFromStorage($meeting->audio_file_path);
|
|
}
|
|
|
|
return $meeting->delete();
|
|
}
|
|
|
|
public function saveSegments(MeetingMinute $meeting, array $segments): MeetingMinute
|
|
{
|
|
// 기존 세그먼트 삭제 후 새로 생성
|
|
$meeting->segments()->delete();
|
|
|
|
$fullTranscript = '';
|
|
|
|
foreach ($segments as $index => $segment) {
|
|
MeetingMinuteSegment::create([
|
|
'meeting_minute_id' => $meeting->id,
|
|
'segment_order' => $index,
|
|
'speaker_name' => $segment['speaker_name'] ?? '화자 1',
|
|
'speaker_label' => $segment['speaker_label'] ?? null,
|
|
'text' => $segment['text'] ?? '',
|
|
'start_time_ms' => $segment['start_time_ms'] ?? 0,
|
|
'end_time_ms' => $segment['end_time_ms'] ?? null,
|
|
'is_manual_speaker' => $segment['is_manual_speaker'] ?? true,
|
|
]);
|
|
|
|
$speakerName = $segment['speaker_name'] ?? '화자 1';
|
|
$text = $segment['text'] ?? '';
|
|
$fullTranscript .= "[{$speakerName}] {$text}\n";
|
|
}
|
|
|
|
$meeting->update([
|
|
'full_transcript' => trim($fullTranscript),
|
|
'status' => MeetingMinute::STATUS_DRAFT,
|
|
]);
|
|
|
|
return $meeting->fresh()->load('segments');
|
|
}
|
|
|
|
public function uploadAudio(MeetingMinute $meeting, $file, int $durationSeconds): bool
|
|
{
|
|
$extension = $file->getClientOriginalExtension() ?: 'webm';
|
|
$objectName = sprintf(
|
|
'meeting-minutes/%d/%d/%s.%s',
|
|
$meeting->tenant_id,
|
|
$meeting->id,
|
|
now()->format('YmdHis'),
|
|
$extension
|
|
);
|
|
|
|
$tempPath = $file->getRealPath();
|
|
$result = $this->googleCloudService->uploadToStorage($tempPath, $objectName);
|
|
|
|
if (! $result) {
|
|
Log::error('MeetingMinute: GCS 오디오 업로드 실패', [
|
|
'meeting_id' => $meeting->id,
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$meeting->update([
|
|
'audio_file_path' => $objectName,
|
|
'audio_gcs_uri' => $result['uri'],
|
|
'audio_file_size' => $result['size'] ?? $file->getSize(),
|
|
'duration_seconds' => $durationSeconds,
|
|
]);
|
|
|
|
AiTokenHelper::saveGcsStorageUsage('회의록-GCS저장', $result['size'] ?? $file->getSize());
|
|
|
|
return true;
|
|
}
|
|
|
|
public function generateSummary(MeetingMinute $meeting): ?array
|
|
{
|
|
if (empty($meeting->full_transcript)) {
|
|
return null;
|
|
}
|
|
|
|
$meeting->update(['status' => MeetingMinute::STATUS_PROCESSING]);
|
|
|
|
$config = AiConfig::getActiveGemini();
|
|
|
|
if (! $config) {
|
|
Log::warning('Gemini API 설정이 없습니다.');
|
|
$meeting->update(['status' => MeetingMinute::STATUS_FAILED]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$prompt = $this->buildSummaryPrompt($meeting->full_transcript);
|
|
|
|
try {
|
|
$result = $this->callGeminiForSummary($config, $prompt);
|
|
|
|
if (! $result) {
|
|
$meeting->update(['status' => MeetingMinute::STATUS_FAILED]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$meeting->update([
|
|
'summary' => $result['summary'] ?? null,
|
|
'decisions' => $result['decisions'] ?? [],
|
|
'action_items' => $result['action_items'] ?? [],
|
|
'status' => MeetingMinute::STATUS_COMPLETED,
|
|
]);
|
|
|
|
return $result;
|
|
} catch (\Exception $e) {
|
|
Log::error('MeetingMinute: Gemini 요약 실패', [
|
|
'meeting_id' => $meeting->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$meeting->update(['status' => MeetingMinute::STATUS_FAILED]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function logSttUsage(int $durationSeconds): void
|
|
{
|
|
AiTokenHelper::saveSttUsage('회의록-음성인식', $durationSeconds);
|
|
}
|
|
|
|
/**
|
|
* 업로드된 오디오에 대해 자동 화자 분리(Speaker Diarization) 실행
|
|
*/
|
|
public function processDiarization(MeetingMinute $meeting, int $minSpeakers = 2, int $maxSpeakers = 6): ?array
|
|
{
|
|
if (empty($meeting->audio_gcs_uri)) {
|
|
return null;
|
|
}
|
|
|
|
$meeting->update(['status' => MeetingMinute::STATUS_PROCESSING]);
|
|
|
|
try {
|
|
$result = $this->googleCloudService->speechToTextWithDiarization(
|
|
$meeting->audio_gcs_uri,
|
|
$meeting->stt_language ?? 'ko-KR',
|
|
$minSpeakers,
|
|
$maxSpeakers
|
|
);
|
|
|
|
if (! $result || empty($result['segments'])) {
|
|
Log::warning('MeetingMinute: 화자 분리 결과 없음', ['meeting_id' => $meeting->id]);
|
|
$meeting->update(['status' => MeetingMinute::STATUS_FAILED]);
|
|
|
|
return null;
|
|
}
|
|
|
|
// 기존 세그먼트 교체
|
|
$meeting->segments()->delete();
|
|
$fullTranscript = '';
|
|
|
|
foreach ($result['segments'] as $index => $segment) {
|
|
MeetingMinuteSegment::create([
|
|
'meeting_minute_id' => $meeting->id,
|
|
'segment_order' => $index,
|
|
'speaker_name' => $segment['speaker_name'] ?? '화자 1',
|
|
'speaker_label' => $segment['speaker_label'] ?? null,
|
|
'text' => $segment['text'] ?? '',
|
|
'start_time_ms' => $segment['start_time_ms'] ?? 0,
|
|
'end_time_ms' => $segment['end_time_ms'] ?? null,
|
|
'is_manual_speaker' => false,
|
|
]);
|
|
|
|
$speakerName = $segment['speaker_name'] ?? '화자 1';
|
|
$text = $segment['text'] ?? '';
|
|
$fullTranscript .= "[{$speakerName}] {$text}\n";
|
|
}
|
|
|
|
$meeting->update([
|
|
'full_transcript' => trim($fullTranscript),
|
|
'status' => MeetingMinute::STATUS_DRAFT,
|
|
]);
|
|
|
|
// STT 사용량 기록
|
|
if ($meeting->duration_seconds > 0) {
|
|
AiTokenHelper::saveSttUsage('회의록-화자분리', $meeting->duration_seconds);
|
|
}
|
|
|
|
return [
|
|
'segments' => $result['segments'],
|
|
'speaker_count' => $result['speaker_count'] ?? 1,
|
|
'full_transcript' => trim($fullTranscript),
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error('MeetingMinute: 화자 분리 실패', [
|
|
'meeting_id' => $meeting->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$meeting->update(['status' => MeetingMinute::STATUS_FAILED]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function buildSummaryPrompt(string $transcript): string
|
|
{
|
|
return <<<PROMPT
|
|
다음은 회의 녹취록입니다. 이 내용을 분석하여 아래 JSON 형식으로 정확히 응답해주세요.
|
|
다른 텍스트 없이 JSON만 응답해주세요.
|
|
|
|
## 응답 형식 (JSON):
|
|
{
|
|
"summary": "회의 전체 요약 (3-5문장)",
|
|
"decisions": ["결정사항 1", "결정사항 2"],
|
|
"action_items": [
|
|
{"assignee": "담당자명", "task": "할일 내용", "deadline": "기한 또는 null"}
|
|
],
|
|
"keywords": ["핵심 키워드1", "핵심 키워드2"]
|
|
}
|
|
|
|
## 주의사항:
|
|
- summary는 회의의 핵심 내용을 간결하게 요약
|
|
- decisions는 회의에서 확정된 결정사항만 포함
|
|
- action_items의 assignee는 녹취록에서 파악 가능한 경우만 기입, 불명확하면 "미정"
|
|
- keywords는 3-5개의 핵심 키워드
|
|
|
|
## 녹취록:
|
|
{$transcript}
|
|
PROMPT;
|
|
}
|
|
|
|
private function callGeminiForSummary(AiConfig $config, string $prompt): ?array
|
|
{
|
|
if ($config->isVertexAi()) {
|
|
$responseText = $this->callVertexAiApi($config, $prompt);
|
|
} else {
|
|
$responseText = $this->callGoogleAiStudioApi($config, $prompt);
|
|
}
|
|
|
|
if (! $responseText) {
|
|
return null;
|
|
}
|
|
|
|
// JSON 파싱 (코드블록 제거)
|
|
$cleaned = preg_replace('/```json\s*/', '', $responseText);
|
|
$cleaned = preg_replace('/```\s*/', '', $cleaned);
|
|
$cleaned = trim($cleaned);
|
|
|
|
$parsed = json_decode($cleaned, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
Log::warning('MeetingMinute: Gemini JSON 파싱 실패', [
|
|
'response' => mb_substr($responseText, 0, 500),
|
|
]);
|
|
|
|
return [
|
|
'summary' => $responseText,
|
|
'decisions' => [],
|
|
'action_items' => [],
|
|
'keywords' => [],
|
|
];
|
|
}
|
|
|
|
return $parsed;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
Log::error('OAuth token request exception', ['error' => $e->getMessage()]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function base64UrlEncode(string $data): string
|
|
{
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
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('MeetingMinute 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('MeetingMinute Gemini API 예외', ['error' => $e->getMessage()]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|