Files
sam-kd/voice_ai/process_meeting.php

671 lines
26 KiB
PHP

<?php
// 출력 버퍼링 시작 - 모든 출력을 캡처
while (ob_get_level()) {
ob_end_clean();
}
ob_start();
// 에러 리포팅 설정 (개발 중에는 활성화, 프로덕션에서는 비활성화)
error_reporting(E_ALL);
ini_set('display_errors', 0); // 출력은 버퍼로만
ini_set('log_errors', 1);
// 에러 핸들러 설정
function handleError($errno, $errstr, $errfile, $errline) {
error_log("PHP Error [$errno]: $errstr in $errfile on line $errline");
return false; // 기본 에러 핸들러도 실행
}
set_error_handler('handleError');
// 치명적 에러 핸들러
function handleFatalError() {
$error = error_get_last();
if ($error !== NULL && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'error' => '서버 오류가 발생했습니다.',
'details' => $error['message'],
'file' => basename($error['file']),
'line' => $error['line']
], JSON_UNESCAPED_UNICODE);
exit;
}
}
register_shutdown_function('handleFatalError');
// 에러 응답 함수 (require 전에 정의)
function sendErrorResponse($message, $details = null) {
while (ob_get_level()) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
$response = ['ok' => false, 'error' => $message];
if ($details !== null) {
$response['details'] = $details;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
// 출력 버퍼 비우기
ob_clean();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
// 1. 권한 체크
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
if (!isset($user_id) || $level > 5) {
sendErrorResponse('접근 권한이 없습니다.');
}
// 2. POST 요청 확인
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendErrorResponse('POST 요청만 허용됩니다.');
}
// 3. 파일 업로드 확인
if (!isset($_FILES['audio_file']) || $_FILES['audio_file']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = '오디오 파일 업로드 실패';
if (isset($_FILES['audio_file']['error'])) {
switch ($_FILES['audio_file']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$errorMsg = '파일 크기가 너무 큽니다.';
break;
case UPLOAD_ERR_PARTIAL:
$errorMsg = '파일이 부분적으로만 업로드되었습니다.';
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = '파일이 업로드되지 않았습니다.';
break;
case UPLOAD_ERR_NO_TMP_DIR:
$errorMsg = '임시 폴더가 없습니다.';
break;
case UPLOAD_ERR_CANT_WRITE:
$errorMsg = '파일 쓰기 실패.';
break;
case UPLOAD_ERR_EXTENSION:
$errorMsg = '파일 업로드가 확장에 의해 중지되었습니다.';
break;
}
}
sendErrorResponse($errorMsg);
}
// tenant_id 확인 및 변환
$tenant_id = isset($tenant_id) ? $tenant_id : (isset($user_id) ? $user_id : null);
// tenant_id가 null이거나 정수가 아닌 경우 처리
if (empty($tenant_id)) {
sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id와 user_id가 모두 없습니다.');
}
// tenant_id가 정수가 아닌 경우 (예: "pro") crc32 해시로 변환
if (!filter_var($tenant_id, FILTER_VALIDATE_INT)) {
$original_tenant_id = $tenant_id;
// null이 아닌 문자열로 변환
$tenant_id_str = (string)$tenant_id;
$tenant_id = crc32($tenant_id_str);
// 최소 1000 이상으로 보장 (낮은 숫자와의 충돌 방지)
if ($tenant_id < 1000) {
$tenant_id = abs($tenant_id) + 1000;
}
error_log("tenant_id 변환: '$original_tenant_id' -> $tenant_id");
}
if (empty($tenant_id) || $tenant_id <= 0) {
sendErrorResponse('유효하지 않은 사용자 ID입니다.', 'tenant_id: ' . var_export($tenant_id, true));
}
// 정수로 변환
$tenant_id = (int)$tenant_id;
// 업로드 디렉토리 생성
$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/meetings/" . $tenant_id . "/";
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
if ($_FILES['audio_file']['size'] == 0) {
sendErrorResponse('오디오 파일이 비어있습니다. 녹음이 제대로 되지 않았을 수 있습니다.');
}
$file_name = date('Ymd_His') . "_" . uniqid() . ".webm";
$file_path = $upload_dir . $file_name;
if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) {
sendErrorResponse('파일 저장 실패', '업로드 디렉토리 권한을 확인해주세요.');
}
// 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 토큰 요청 실패', 'details' => $errorMessage]);
exit;
}
}
// API 키 사용 (서비스 계정이 없는 경우)
$googleApiKey = null;
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
if (strlen($googleApiKey) < 20) {
echo json_encode(['ok' => false, 'error' => 'Google API 키 형식이 올바르지 않습니다.']);
exit;
}
}
if (!$accessToken && !$googleApiKey) {
echo json_encode(['ok' => false, 'error' => 'Google API 인증 정보가 없습니다. 서비스 계정 JSON 파일 또는 API 키가 필요합니다.']);
exit;
}
// 오디오 파일 읽기
$file_size = filesize($file_path);
$audio_content = file_get_contents($file_path);
// 파일 크기 확인 (60MB 제한)
if ($file_size > 60 * 1024 * 1024) {
echo json_encode([
'ok' => false,
'error' => '오디오 파일이 너무 큽니다. (최대 60MB)',
'file_size_mb' => round($file_size / 1024 / 1024, 2)
]);
exit;
}
// 오디오 인코딩 및 샘플레이트 자동 감지
$audio_encoding = 'WEBM_OPUS';
$sample_rate = 48000;
// 파일 확장자나 MIME 타입으로 인코딩 추정
$file_ext = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if ($file_ext === 'wav') {
$audio_encoding = 'LINEAR16';
$sample_rate = 16000;
} elseif ($file_ext === 'flac') {
$audio_encoding = 'FLAC';
$sample_rate = 48000;
} elseif ($file_ext === 'mp3' || $file_ext === 'm4a') {
$audio_encoding = 'MP3';
$sample_rate = 44100;
}
// 짧은 오디오(약 1MB 이하, 약 1분 이하)는 recognize API 사용 (즉시 결과)
// 긴 오디오는 longrunningrecognize API 사용 (비동기 처리)
$useRecognizeAPI = $file_size <= 1024 * 1024; // 1MB 이하
if ($useRecognizeAPI) {
// 짧은 오디오: recognize API 사용 (동기, 즉시 결과)
$apiUrl = 'https://speech.googleapis.com/v1/speech:recognize';
error_log('짧은 오디오 감지: recognize API 사용 (파일 크기: ' . round($file_size / 1024, 2) . ' KB)');
} else {
// 긴 오디오: longrunningrecognize API 사용 (비동기)
$apiUrl = 'https://speech.googleapis.com/v1/speech:longrunningrecognize';
error_log('긴 오디오 감지: longrunningrecognize API 사용 (파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB)');
}
$requestBody = [
'config' => [
'encoding' => $audio_encoding,
'sampleRateHertz' => $sample_rate,
'languageCode' => 'ko-KR',
'enableAutomaticPunctuation' => true,
'enableWordTimeOffsets' => false,
'model' => $useRecognizeAPI ? 'latest_short' : 'latest_long' // 짧은 오디오는 latest_short 모델 사용
],
'audio' => [
'content' => base64_encode($audio_content)
]
];
$headers = ['Content-Type: application/json'];
if ($accessToken) {
$headers[] = 'Authorization: Bearer ' . $accessToken;
} else {
$apiUrl .= '?key=' . urlencode($googleApiKey);
}
$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);
// 타임아웃 설정 (짧은 오디오는 더 짧게, 긴 오디오는 길게)
curl_setopt($ch, CURLOPT_TIMEOUT, $useRecognizeAPI ? 30 : 50); // recognize는 30초, longrunning은 50초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
$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_message = '';
$error_data = json_decode($operation_response, true);
if (isset($error_data['error']['message'])) {
$error_message = $error_data['error']['message'];
}
// 파일이 너무 큰 경우 GCS 사용 시도
if ($operation_code === 400 && (
strpos($error_message, 'too large') !== false ||
strpos($error_message, 'exceeds') !== false ||
$file_size > 10 * 1024 * 1024 // 10MB 이상
)) {
// Google Cloud Storage 업로드 시도
$gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt';
if (file_exists($gcs_config_file)) {
$gcs_config = json_decode(file_get_contents($gcs_config_file), true);
$bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : '';
if (!empty($bucket_name)) {
// GCS에 업로드
$gcs_object_name = 'meetings/' . $tenant_id . '/' . $file_name;
$gcs_uri = 'gs://' . $bucket_name . '/' . $gcs_object_name;
// 간단한 GCS 업로드 (서비스 계정 사용)
if ($accessToken) {
$gcs_upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . $bucket_name . '/o?uploadType=media&name=' . urlencode($gcs_object_name);
$gcs_headers = [
'Authorization: Bearer ' . $accessToken,
'Content-Type: audio/webm'
];
$gcs_ch = curl_init($gcs_upload_url);
curl_setopt($gcs_ch, CURLOPT_POST, true);
curl_setopt($gcs_ch, CURLOPT_HTTPHEADER, $gcs_headers);
curl_setopt($gcs_ch, CURLOPT_POSTFIELDS, $audio_content);
curl_setopt($gcs_ch, CURLOPT_RETURNTRANSFER, true);
$gcs_response = curl_exec($gcs_ch);
$gcs_code = curl_getinfo($gcs_ch, CURLINFO_HTTP_CODE);
curl_close($gcs_ch);
if ($gcs_code === 200) {
// GCS 업로드 성공
error_log('GCS 업로드 성공: ' . $gcs_uri);
} else {
error_log('GCS 업로드 실패 (HTTP ' . $gcs_code . '): ' . $gcs_response);
$gcs_uri = null;
}
} else {
$gcs_uri = null;
}
} else {
$gcs_uri = null;
}
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);
// $web_path 정의
$web_path = "/uploads/meetings/" . $tenant_id . "/" . $file_name;
if ($useRecognizeAPI) {
// recognize API: 즉시 결과 반환
if (!isset($operation_data['results']) || empty($operation_data['results'])) {
error_log('Google STT recognize API 결과 없음: ' . $operation_response);
echo json_encode([
'ok' => false,
'error' => '음성 인식 결과가 없습니다.',
'details' => '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.'
]);
exit;
}
// 텍스트 변환
$transcript = '';
foreach ($operation_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' => '인식된 텍스트가 없습니다.',
'details' => '녹음된 오디오에서 텍스트를 추출할 수 없었습니다.'
]);
exit;
}
// Claude API로 요약 생성
$claudeKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/claude_api.txt';
$claudeKey = '';
if (file_exists($claudeKeyFile)) {
$claudeKey = trim(file_get_contents($claudeKeyFile));
}
$title = '무제 회의록';
$summary = '';
if (!empty($claudeKey)) {
$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);
curl_setopt($ch2, CURLOPT_TIMEOUT, 30);
$ai_response = curl_exec($ch2);
$ai_code = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
curl_close($ch2);
if ($ai_code === 200) {
$ai_data = json_decode($ai_response, true);
if (isset($ai_data['content'][0]['text'])) {
$ai_text = trim($ai_data['content'][0]['text']);
$ai_text = preg_replace('/^```(?:json)?\s*/m', '', $ai_text);
$ai_text = preg_replace('/\s*```$/m', '', $ai_text);
$ai_text = trim($ai_text);
$parsed_data = json_decode($ai_text, true);
if (is_array($parsed_data)) {
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;
}
} else {
$summary = $ai_text;
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');
}
}
}
}
$title = mb_substr(trim($title), 0, 20, 'UTF-8');
if (empty($title)) {
$title = '무제 회의록';
}
if (empty($summary)) {
$summary = "Claude API 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)";
}
// DB에 저장
$pdo = db_connect();
$insertSql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$insertStmt = $pdo->prepare($insertSql);
$expiry = date('Y-m-d H:i:s', strtotime('+7 days'));
$insertStmt->execute([$tenant_id, $tenant_id, $title, $web_path, $transcript, $summary, $expiry]);
$meeting_id = $pdo->lastInsertId();
// 즉시 완료 응답
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => false,
'done' => true,
'meeting_id' => $meeting_id,
'title' => $title,
'transcript' => $transcript,
'summary' => $summary,
'message' => '음성 인식 및 요약이 완료되었습니다.'
], JSON_UNESCAPED_UNICODE);
exit;
} else {
// longrunningrecognize API: 비동기 처리
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'];
// 504 Gateway Timeout 방지를 위해 비동기 처리로 변경
// 작업을 시작하고 즉시 응답, 클라이언트에서 폴링하도록 변경
// 작업 정보를 임시로 DB에 저장
$pdo_temp = db_connect();
$temp_sql = "INSERT INTO meeting_logs (tenant_id, user_id, title, audio_file_path, transcript_text, summary_text, file_expiry_date)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$temp_stmt = $pdo_temp->prepare($temp_sql);
$temp_title = '처리 중...';
$temp_summary = 'Google Speech-to-Text API 처리 중입니다. 잠시 후 다시 시도해주세요.';
$temp_expiry = date('Y-m-d H:i:s', strtotime('+7 days'));
// transcript_text에 operation_name 저장 (나중에 업데이트)
$temp_stmt->execute([$tenant_id, $tenant_id, $temp_title, $web_path, 'OPERATION_NAME:' . $operation_name, $temp_summary, $temp_expiry]);
$temp_meeting_id = $pdo_temp->lastInsertId();
// 즉시 응답 (클라이언트에서 폴링하도록)
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => true,
'meeting_id' => $temp_meeting_id,
'operation_name' => $operation_name,
'message' => '음성 인식 작업이 시작되었습니다. 처리 완료까지 시간이 걸릴 수 있습니다.',
'poll_url' => 'check_meeting_status.php?meeting_id=' . $temp_meeting_id . '&operation_name=' . urlencode($operation_name),
'access_token_available' => !empty($accessToken)
], JSON_UNESCAPED_UNICODE);
exit;
}
// 비동기 처리: 작업이 시작되었으므로 즉시 응답하고 종료
// 나머지 처리는 check_meeting_status.php에서 클라이언트 폴링으로 처리됨
// 이전 동기 처리 코드는 check_meeting_status.php로 이동됨
?>