'RS256', 'typ' => 'JWT'])); $jwtClaim = base64_encode(json_encode([ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (!$privateKey) { error_log('GCS 업로드 실패: 개인 키 읽기 오류'); return false; } openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); openssl_free_key($privateKey); $jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature); // OAuth 토큰 요청 $tokenCh = curl_init('https://oauth2.googleapis.com/token'); curl_setopt($tokenCh, CURLOPT_POST, true); curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt ])); curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); $tokenResponse = curl_exec($tokenCh); $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); curl_close($tokenCh); if ($tokenCode !== 200) { error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')'); return false; } $tokenData = json_decode($tokenResponse, true); if (!isset($tokenData['access_token'])) { error_log('GCS 업로드 실패: OAuth 토큰 없음'); return false; } $accessToken = $tokenData['access_token']; // GCS에 파일 업로드 $file_content = file_get_contents($file_path); $mime_type = mime_content_type($file_path) ?: 'audio/webm'; $upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . urlencode($bucket_name) . '/o?uploadType=media&name=' . urlencode($object_name); $ch = curl_init($upload_url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: ' . $mime_type, 'Content-Length: ' . strlen($file_content) ]); curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $response = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200) { return 'gs://' . $bucket_name . '/' . $object_name; } else { error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response); return false; } } // 출력 버퍼 비우기 ob_clean(); header('Content-Type: application/json; charset=utf-8'); // 1. 권한 및 세션 체크 if (!isset($user_id) || $level > 5) { echo json_encode(['ok' => false, 'error' => '접근 권한이 없습니다.']); exit; } $tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용 $upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/meetings/" . $tenant_id . "/"; // 2. 파일 업로드 처리 if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true); if (!isset($_FILES['audio_file'])) { echo json_encode(['ok' => false, 'error' => '오디오 파일이 없습니다.']); exit; } // 파일 크기 확인 if ($_FILES['audio_file']['size'] == 0) { echo json_encode(['ok' => false, 'error' => '오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.']); exit; } $file_name = date('Ymd_His') . "_" . uniqid() . ".webm"; $file_path = $upload_dir . $file_name; if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) { echo json_encode(['ok' => false, 'error' => '파일 저장 실패']); exit; } // 3. STT 변환 (Google Cloud Speech-to-Text API) // 서비스 계정 JSON 파일 또는 API 키 사용 $googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json'; $googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt'; $accessToken = null; // 서비스 계정 JSON 파일이 있으면 OAuth 2.0 토큰 생성 if (file_exists($googleServiceAccountFile)) { $serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true); if (!$serviceAccount) { echo json_encode(['ok' => false, 'error' => '서비스 계정 JSON 파일 형식이 올바르지 않습니다.']); exit; } // JWT 생성 및 OAuth 2.0 토큰 요청 $now = time(); // JWT 헤더 $jwtHeader = [ 'alg' => 'RS256', 'typ' => 'JWT' ]; // JWT 클레임 $jwtClaim = [ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now ]; // Base64 URL 인코딩 (표준 JWT 형식) $base64UrlEncode = function($data) { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }; $encodedHeader = $base64UrlEncode(json_encode($jwtHeader)); $encodedClaim = $base64UrlEncode(json_encode($jwtClaim)); // 서명 생성 $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); if (!$privateKey) { echo json_encode(['ok' => false, 'error' => '서비스 계정 개인 키를 읽을 수 없습니다.']); exit; } $signature = ''; $signData = $encodedHeader . '.' . $encodedClaim; if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { openssl_free_key($privateKey); echo json_encode(['ok' => false, 'error' => 'JWT 서명 생성 실패']); exit; } openssl_free_key($privateKey); $encodedSignature = $base64UrlEncode($signature); $jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature; // OAuth 2.0 토큰 요청 $tokenCh = curl_init('https://oauth2.googleapis.com/token'); curl_setopt($tokenCh, CURLOPT_POST, true); curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt ])); curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); $tokenResponse = curl_exec($tokenCh); $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); $tokenError = curl_error($tokenCh); curl_close($tokenCh); if ($tokenCode === 200) { $tokenData = json_decode($tokenResponse, true); if (isset($tokenData['access_token'])) { $accessToken = $tokenData['access_token']; error_log('OAuth 토큰 생성 성공'); } else { error_log('OAuth 토큰 응답 오류: ' . $tokenResponse); echo json_encode(['ok' => false, 'error' => 'OAuth 토큰을 받을 수 없습니다.', 'details' => substr($tokenResponse, 0, 500)]); exit; } } else { error_log('OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . '): ' . $tokenResponse); $errorDetails = json_decode($tokenResponse, true); $errorMessage = isset($errorDetails['error_description']) ? $errorDetails['error_description'] : (isset($errorDetails['error']) ? $errorDetails['error'] : substr($tokenResponse, 0, 500)); echo json_encode(['ok' => false, 'error' => 'OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')', 'details' => $errorMessage]); exit; } } // OAuth 토큰이 없고 API 키 파일이 있으면 API 키 사용 if (!$accessToken && file_exists($googleApiKeyFile)) { $googleApiKey = trim(file_get_contents($googleApiKeyFile)); if (!empty($googleApiKey)) { // API 키 방식 사용 (기존 코드) } else { echo json_encode(['ok' => false, 'error' => 'Google API 키가 비어있습니다.']); exit; } } elseif (!$accessToken) { echo json_encode(['ok' => false, 'error' => 'Google 서비스 계정 JSON 파일 또는 API 키 파일이 필요합니다.']); exit; } // 오디오 파일 크기 확인 $file_size = filesize($file_path); $max_inline_size = 10 * 1024 * 1024; // 10MB (인라인 오디오 제한) // 파일 확장자 확인 $file_extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION)); // 인코딩 자동 감지 (파일 확장자 기반) $encoding = null; $sample_rate = null; switch ($file_extension) { case 'webm': $encoding = 'WEBM_OPUS'; $sample_rate = 48000; break; case 'wav': $encoding = 'LINEAR16'; // WAV 파일은 LINEAR16 $sample_rate = 16000; // WAV는 보통 16kHz break; case 'mp3': $encoding = 'MP3'; $sample_rate = null; // MP3는 자동 감지 break; case 'ogg': $encoding = 'OGG_OPUS'; $sample_rate = 48000; break; case 'm4a': $encoding = 'MP4'; $sample_rate = null; break; default: // 기본값: webm으로 가정 $encoding = 'WEBM_OPUS'; $sample_rate = 48000; } // API 요청 본문 구성 $config = [ 'languageCode' => 'ko-KR', // 한국어 'enableAutomaticPunctuation' => true, 'model' => 'latest_long', // 긴 오디오용 모델 'audioChannelCount' => 1, // 모노 채널 'enableWordTimeOffsets' => false // 단어별 타임스탬프는 필요시에만 ]; // 인코딩 설정 (일부 형식은 생략 가능) if ($encoding) { $config['encoding'] = $encoding; } // 샘플레이트 설정 (필요한 경우만) if ($sample_rate) { $config['sampleRateHertz'] = $sample_rate; } $requestBody = ['config' => $config]; // 오디오 소스 설정: 작은 파일은 인라인, 큰 파일은 GCS URI 사용 // 먼저 인라인으로 시도하고, 오류 발생 시 재시도 로직으로 처리 $audioContent = base64_encode(file_get_contents($file_path)); $requestBody['audio'] = ['content' => $audioContent]; // 모든 오디오 길이를 지원하기 위해 longrunningrecognize API 사용 // (짧은 오디오도 처리 가능하며, 1분 제한이 없음) // longrunningrecognize는 비동기 작업이므로 작업을 시작하고 폴링해야 함 if ($accessToken) { $apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize'; $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken ]; } else { $apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize?key=' . urlencode($googleApiKey); $headers = ['Content-Type: application/json']; } $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $operation_response = curl_exec($ch); $operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $operation_error = curl_error($ch); curl_close($ch); if ($operation_code !== 200) { error_log('Google STT longrunningrecognize 시작 실패: ' . $operation_response); // 오류 응답 파싱 $error_data = json_decode($operation_response, true); $error_message = ''; $needs_gcs = false; $needs_encoding_fix = false; if (isset($error_data['error']['message'])) { $error_message = $error_data['error']['message']; // 인코딩 오류 감지 if (strpos($error_message, 'Encoding') !== false || strpos($error_message, 'LINEAR16') !== false || strpos($error_message, 'must either be omitted') !== false) { $needs_encoding_fix = true; } // GCS URI 필요 오류인 경우 감지 if (strpos($error_message, 'GCS URI') !== false || strpos($error_message, 'duration limit') !== false || strpos($error_message, 'exceeds duration limit') !== false || strpos($error_message, 'Inline audio exceeds') !== false) { $needs_gcs = true; } } // 인코딩 오류인 경우, 인코딩을 생략하고 재시도 if ($needs_encoding_fix) { // 인코딩과 샘플레이트를 제거하고 자동 감지하도록 설정 unset($requestBody['config']['encoding']); unset($requestBody['config']['sampleRateHertz']); // 재시도 $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $operation_response = curl_exec($ch); $operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $operation_error = curl_error($ch); curl_close($ch); // 인코딩 수정 후 성공한 경우, 이후 로직으로 진행 if ($operation_code === 200) { // 성공했으므로 이후 폴링 로직으로 진행 $needs_gcs = false; } else { error_log('Google STT 재시도 실패 (인코딩 수정): ' . $operation_response); // 인코딩 수정 후에도 실패한 경우, 오류 데이터 다시 파싱 $error_data = json_decode($operation_response, true); if (isset($error_data['error']['message'])) { $error_message = $error_data['error']['message']; // GCS URI 필요 오류인지 다시 확인 if (strpos($error_message, 'GCS URI') !== false || strpos($error_message, 'duration limit') !== false || strpos($error_message, 'exceeds duration limit') !== false || strpos($error_message, 'Inline audio exceeds') !== false) { $needs_gcs = true; } } } } // GCS URI가 필요한 경우, GCS에 업로드 후 URI 사용 if ($needs_gcs && $operation_code !== 200) { // GCS 설정 확인 $gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt'; $bucket_name = null; if (file_exists($gcs_config_file)) { $gcs_config = parse_ini_file($gcs_config_file); $bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null; } if ($bucket_name) { // GCS에 파일 업로드 $gcs_object_name = 'meetings/' . $tenant_id . '/' . basename($file_path); $gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile); if ($gcs_uri) { // GCS URI 사용 $requestBody['audio'] = ['uri' => $gcs_uri]; } else { // GCS 업로드 실패 echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패', 'details' => 'GCS 업로드에 실패했습니다. GCS 설정을 확인해주세요.', 'file_size_mb' => round($file_size / 1024 / 1024, 2) ]); exit; } } else { // GCS 설정이 없는 경우 echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패', 'details' => '오디오 파일이 너무 깁니다. Google Cloud Storage 설정이 필요합니다. /apikey/gcs_config.txt 파일을 생성하고 bucket_name을 설정해주세요.', 'file_size_mb' => round($file_size / 1024 / 1024, 2), 'help' => '자세한 설정 방법은 voice_ai/구글클라우드스토리지설정방법.md 파일을 참고하세요.' ]); exit; } // 재시도 $ch = curl_init($apiUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $operation_response = curl_exec($ch); $operation_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $operation_error = curl_error($ch); curl_close($ch); // 재시도도 실패한 경우 if ($operation_code !== 200) { error_log('Google STT 재시도 실패 (GCS URI): ' . $operation_response); echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패 (HTTP ' . $operation_code . ')', 'details' => '오디오 파일이 너무 깁니다. Google Cloud Storage 설정이 필요할 수 있습니다.', 'curl_error' => $operation_error, 'file_size_mb' => round($file_size / 1024 / 1024, 2) ]); exit; } // 재시도 성공 시 계속 진행 } else { // 다른 오류인 경우 echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패 (HTTP ' . $operation_code . ')', 'details' => $error_message ?: ($operation_response ? substr($operation_response, 0, 500) : '응답 없음'), 'curl_error' => $operation_error ]); exit; } } $operation_data = json_decode($operation_response, true); if (!isset($operation_data['name'])) { error_log('Google STT longrunningrecognize 작업 ID 없음: ' . $operation_response); echo json_encode([ 'ok' => false, 'error' => 'Google STT 작업 시작 실패', 'details' => substr($operation_response, 0, 500) ]); exit; } $operation_name = $operation_data['name']; // 작업 완료까지 폴링 (오디오 길이에 따라 동적으로 타임아웃 설정) // 4시간 오디오는 처리에 약 1~2시간 소요될 수 있음 // 최대 4시간 대기 (2880회 * 5초 = 4시간) // PHP 실행 시간 제한도 충분히 설정 필요 (ini_set('max_execution_time', 14400)) ini_set('max_execution_time', 14400); // 4시간 set_time_limit(14400); // 4시간 $max_polls = 2880; // 2880회 * 5초 = 4시간 $poll_count = 0; $operation_done = false; while ($poll_count < $max_polls && !$operation_done) { sleep(5); // 5초 대기 $poll_count++; // 작업 상태 확인 if ($accessToken) { $poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name); $poll_headers = ['Authorization: Bearer ' . $accessToken]; } else { $poll_url = 'https://speech.googleapis.com/v1/operations/' . urlencode($operation_name) . '?key=' . urlencode($googleApiKey); $poll_headers = []; } $poll_ch = curl_init($poll_url); curl_setopt($poll_ch, CURLOPT_RETURNTRANSFER, true); if (!empty($poll_headers)) { curl_setopt($poll_ch, CURLOPT_HTTPHEADER, $poll_headers); } $poll_response = curl_exec($poll_ch); $poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE); curl_close($poll_ch); if ($poll_code === 200) { $poll_data = json_decode($poll_response, true); if (isset($poll_data['done']) && $poll_data['done'] === true) { if (isset($poll_data['error'])) { error_log('Google STT longrunningrecognize 오류: ' . json_encode($poll_data['error'])); echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패', 'details' => isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류' ]); exit; } if (isset($poll_data['response']['results'])) { $stt_data = ['results' => $poll_data['response']['results']]; $operation_done = true; } else { error_log('Google STT longrunningrecognize 결과 없음: ' . $poll_response); echo json_encode([ 'ok' => false, 'error' => 'Google STT 응답에 결과가 없습니다.', 'details' => substr($poll_response, 0, 500) ]); exit; } } } else { error_log('Google STT longrunningrecognize 폴링 실패 (HTTP ' . $poll_code . '): ' . $poll_response); } } if (!$operation_done) { echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환이 시간 초과되었습니다. (4시간 이상 소요)', 'details' => '작업이 아직 완료되지 않았습니다. 매우 긴 오디오(4시간 이상)는 처리 시간이 오래 걸릴 수 있습니다. 작업 ID: ' . $operation_name, 'operation_name' => $operation_name, 'poll_count' => $poll_count, 'elapsed_time_minutes' => round($poll_count * 5 / 60, 1) ]); exit; } // Google Speech-to-Text 응답 형식 처리 if (!isset($stt_data['results']) || empty($stt_data['results'])) { // 오류 응답인 경우 처리 if (isset($stt_data['error'])) { $errorMsg = isset($stt_data['error']['message']) ? $stt_data['error']['message'] : '알 수 없는 오류'; echo json_encode([ 'ok' => false, 'error' => 'Google STT API 오류: ' . $errorMsg, 'response' => substr($stt_response, 0, 500) ]); } else { echo json_encode([ 'ok' => false, 'error' => 'Google STT 응답에 결과가 없습니다. (오디오가 너무 짧거나 인식할 수 없는 형식일 수 있습니다)', 'response' => substr($stt_response, 0, 500) ]); } exit; } // 모든 결과를 합쳐서 텍스트 생성 $transcript = ''; foreach ($stt_data['results'] as $result) { if (isset($result['alternatives'][0]['transcript'])) { $transcript .= $result['alternatives'][0]['transcript'] . ' '; } } $transcript = trim($transcript); if (empty($transcript)) { echo json_encode([ 'ok' => false, 'error' => '인식된 텍스트가 없습니다. (오디오에 음성이 없거나 너무 작을 수 있습니다)', 'response' => substr($stt_response, 0, 500) ]); exit; } // 4. 회의록 요약 및 제목 생성 (Claude API) $claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt'; if (!file_exists($claudeKeyFile)) { // Claude API 키가 없어도 요약은 실패하지만 계속 진행 $claudeKey = ''; } else { $claudeKey = trim(file_get_contents($claudeKeyFile)); } // Claude API에 JSON 형식으로 응답 요청 $prompt = "다음 회의 녹취록을 분석하여 JSON 형식으로 응답해주세요. 요구사항: 1. \"title\": 회의 내용을 요약한 제목 (최대 20자, 한글 기준) 2. \"summary\": [회의 개요 / 주요 안건 / 결정 사항 / 향후 계획] 형식의 상세 요약 반드시 다음 JSON 형식으로만 응답해주세요: { \"title\": \"제목 (최대 20자)\", \"summary\": \"상세 요약 내용\" } 회의 녹취록: " . $transcript; $ch2 = curl_init('https://api.anthropic.com/v1/messages'); $requestBody = [ 'model' => 'claude-3-5-haiku-20241022', 'max_tokens' => 2048, 'messages' => [['role' => 'user', 'content' => $prompt]] ]; curl_setopt($ch2, CURLOPT_POST, true); curl_setopt($ch2, CURLOPT_HTTPHEADER, [ 'x-api-key: ' . $claudeKey, 'anthropic-version: 2023-06-01', 'Content-Type: application/json' ]); curl_setopt($ch2, CURLOPT_POSTFIELDS, json_encode($requestBody)); curl_setopt($ch2, CURLOPT_RETURNTRANSFER, true); $ai_response = curl_exec($ch2); $ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE); curl_close($ch2); // 기본값 설정 $title = '무제 회의록'; $summary = ''; if ($ai_code !== 200 || empty($claudeKey)) { if (empty($claudeKey)) { $summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)"; } else { $summary = "요약 실패 (원문 저장됨)"; } } else { $ai_data = json_decode($ai_response, true); if (isset($ai_data['content'][0]['text'])) { $ai_text = $ai_data['content'][0]['text']; // JSON 추출 시도 (코드 블록이나 마크다운 제거) $ai_text = trim($ai_text); // ```json 또는 ``` 제거 $ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text); $ai_text = preg_replace('/\s*```$/m', '', $ai_text); $ai_text = trim($ai_text); // JSON 파싱 시도 $parsed_data = json_decode($ai_text, true); if (is_array($parsed_data)) { // JSON 파싱 성공 if (isset($parsed_data['title']) && !empty($parsed_data['title'])) { $title = mb_substr(trim($parsed_data['title']), 0, 20, 'UTF-8'); } if (isset($parsed_data['summary']) && !empty($parsed_data['summary'])) { $summary = $parsed_data['summary']; } else { $summary = $ai_text; // JSON 형식이 아니면 전체 텍스트 사용 } } else { // JSON 파싱 실패 - 텍스트에서 제목 추출 시도 $summary = $ai_text; // 텍스트에서 "title" 또는 "제목" 키워드로 제목 추출 시도 if (preg_match('/["\']?title["\']?\s*[:=]\s*["\']([^"\']{1,20})["\']/', $ai_text, $matches)) { $title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8'); } elseif (preg_match('/제목[:\s]+([^\n]{1,20})/', $ai_text, $matches)) { $title = mb_substr(trim($matches[1]), 0, 20, 'UTF-8'); } else { // 요약 텍스트의 첫 줄이나 첫 문장을 제목으로 사용 $lines = explode("\n", $ai_text); $first_line = trim($lines[0]); if (!empty($first_line) && mb_strlen($first_line, 'UTF-8') <= 20) { $title = $first_line; } elseif (!empty($first_line)) { $title = mb_substr($first_line, 0, 20, 'UTF-8'); } } } // 제목이 비어있으면 기본값 사용 if (empty($title) || $title === '무제 회의록') { // 요약에서 의미있는 키워드 추출 시도 if (!empty($summary)) { // 요약의 첫 문장이나 주요 키워드를 제목으로 사용 $summary_lines = explode("\n", $summary); $first_summary_line = trim($summary_lines[0]); if (!empty($first_summary_line)) { // 괄호나 특수문자 제거 $first_summary_line = preg_replace('/[\[\(].*?[\]\)]/', '', $first_summary_line); $first_summary_line = trim($first_summary_line); if (mb_strlen($first_summary_line, 'UTF-8') <= 20) { $title = $first_summary_line; } else { $title = mb_substr($first_summary_line, 0, 20, 'UTF-8'); } } } } } else { $summary = "요약 응답 형식 오류 (원문 저장됨)"; } } // 제목 최종 검증 (20자 제한, 빈 값 방지) $title = mb_substr(trim($title), 0, 20, 'UTF-8'); if (empty($title)) { $title = '무제 회의록'; } // 5. DB 저장 및 1주일 보관 설정 $pdo = db_connect(); $expiry_date = date('Y-m-d H:i:s', strtotime('+7 days')); $web_path = "/uploads/meetings/" . $tenant_id . "/" . $file_name; // SQL 인젝션 방지는 PDO Prepared Statement 사용 권장 $sql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date) VALUES (?, ?, ?, ?, ?, ?, ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry_date]); echo json_encode([ 'ok' => true, 'id' => $pdo->lastInsertId(), 'title' => $title, 'transcript' => $transcript, 'summary' => $summary ]); ?>