- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
759 lines
29 KiB
PHP
759 lines
29 KiB
PHP
<?php
|
|
// 출력 버퍼링 시작 및 에러 리포팅 비활성화
|
|
error_reporting(0);
|
|
ob_start();
|
|
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
|
|
|
// GCS 업로드 함수
|
|
function uploadToGCS($file_path, $bucket_name, $object_name, $service_account_path) {
|
|
if (!file_exists($service_account_path)) {
|
|
error_log('GCS 업로드 실패: 서비스 계정 파일 없음');
|
|
return false;
|
|
}
|
|
|
|
$serviceAccount = json_decode(file_get_contents($service_account_path), true);
|
|
if (!$serviceAccount) {
|
|
error_log('GCS 업로드 실패: 서비스 계정 JSON 파싱 오류');
|
|
return false;
|
|
}
|
|
|
|
// OAuth 2.0 토큰 생성
|
|
$now = time();
|
|
$jwtHeader = base64_encode(json_encode(['alg' => '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
|
|
]);
|
|
?>
|