Files
sam-kd/voice_ai_cnslt/process_consult.php

836 lines
34 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/consults/" . $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) {
sendErrorResponse('서비스 계정 JSON 파일 형식이 올바르지 않습니다.');
}
// 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) {
sendErrorResponse('서비스 계정 개인 키를 읽을 수 없습니다.');
}
$signature = '';
$signData = $encodedHeader . '.' . $encodedClaim;
if (!openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
openssl_free_key($privateKey);
sendErrorResponse('JWT 서명 생성 실패');
}
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']);
// 타임아웃 설정
curl_setopt($tokenCh, CURLOPT_TIMEOUT, 30); // 30초
curl_setopt($tokenCh, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 10초
$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);
sendErrorResponse('OAuth 토큰을 받을 수 없습니다.', substr($tokenResponse, 0, 500));
}
} 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));
sendErrorResponse('OAuth 토큰 요청 실패', $errorMessage);
}
}
// API 키 사용 (서비스 계정이 없는 경우)
$googleApiKey = null;
if (!$accessToken && file_exists($googleApiKeyFile)) {
$googleApiKey = trim(file_get_contents($googleApiKeyFile));
if (strlen($googleApiKey) < 20) {
sendErrorResponse('Google API 키 형식이 올바르지 않습니다.');
}
}
if (!$accessToken && !$googleApiKey) {
sendErrorResponse('Google API 인증 정보가 없습니다. 서비스 계정 JSON 파일 또는 API 키가 필요합니다.');
}
// 오디오 파일 읽기
$file_size = filesize($file_path);
$audio_content = file_get_contents($file_path);
// 파일 크기 확인 (60MB 제한)
if ($file_size > 60 * 1024 * 1024) {
sendErrorResponse('오디오 파일이 너무 큽니다. (최대 60MB)', 'file_size_mb: ' . round($file_size / 1024 / 1024, 2));
}
// 오디오 인코딩 및 샘플레이트 자동 감지
$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'];
}
// 상세 오류 로깅
error_log('Google STT API 오류 (HTTP ' . $operation_code . '): ' . $error_message);
error_log('응답 전체: ' . substr($operation_response, 0, 1000));
error_log('파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB');
// 파일이 너무 크거나 오디오 길이가 긴 경우 GCS 사용 시도
$shouldTryGCS = false;
if ($operation_code === 400) {
// 오류 메시지에 다음 키워드가 포함되면 GCS 시도
$gcs_keywords = [
'too large',
'exceeds',
'size limit',
'duration limit',
'Inline audio exceeds',
'Please use a GCS URI',
'GCS URI'
];
foreach ($gcs_keywords as $keyword) {
if (stripos($error_message, $keyword) !== false) {
$shouldTryGCS = true;
error_log('GCS 업로드 필요 감지: ' . $keyword . ' 키워드 발견');
break;
}
}
// 파일 크기가 10MB 이상이면 GCS 시도
if (!$shouldTryGCS && $file_size > 10 * 1024 * 1024) {
$shouldTryGCS = true;
error_log('GCS 업로드 필요 감지: 파일 크기 ' . round($file_size / 1024 / 1024, 2) . ' MB');
}
}
if ($shouldTryGCS) {
// Google Cloud Storage 업로드 시도
$gcs_config_paths = [
$_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt',
$_SERVER['DOCUMENT_ROOT'] . '/5130/apikey/gcs_config.txt',
dirname($_SERVER['DOCUMENT_ROOT']) . '/apikey/gcs_config.txt',
dirname($_SERVER['DOCUMENT_ROOT']) . '/5130/apikey/gcs_config.txt',
];
$gcs_config_file = null;
$bucket_name = '';
// GCS 설정 파일 찾기
foreach ($gcs_config_paths as $path) {
if (file_exists($path)) {
$gcs_config_file = $path;
$gcs_config = json_decode(file_get_contents($path), true);
if ($gcs_config && isset($gcs_config['bucket_name'])) {
$bucket_name = $gcs_config['bucket_name'];
}
break;
}
}
if (empty($bucket_name)) {
// GCS 설정이 없는 경우
$error_details = '원래 오류: ' . $error_message .
' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' .
' / Google Cloud Storage 설정이 필요합니다.';
if ($gcs_config_file) {
$error_details .= ' / GCS 설정 파일은 찾았지만 bucket_name이 설정되지 않았습니다: ' . $gcs_config_file;
} else {
$error_details .= ' / 다음 경로에서 GCS 설정 파일을 찾을 수 없습니다: ' . implode(', ', $gcs_config_paths);
}
$error_details .= ' / /apikey/gcs_config.txt 파일을 생성하고 {"bucket_name": "your-bucket-name"} 형식으로 설정해주세요.';
sendErrorResponse('Google STT 변환 실패', $error_details);
}
// GCS에 업로드
$gcs_object_name = 'consults/' . $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'
];
error_log('GCS 업로드 시도: ' . $gcs_upload_url);
error_log('GCS 버킷: ' . $bucket_name);
error_log('GCS 객체명: ' . $gcs_object_name);
error_log('파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB');
$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);
curl_setopt($gcs_ch, CURLOPT_TIMEOUT, 60);
curl_setopt($gcs_ch, CURLOPT_CONNECTTIMEOUT, 10);
$gcs_response = curl_exec($gcs_ch);
$gcs_code = curl_getinfo($gcs_ch, CURLINFO_HTTP_CODE);
$gcs_error = curl_error($gcs_ch);
curl_close($gcs_ch);
if ($gcs_code === 200) {
// GCS 업로드 성공
error_log('GCS 업로드 성공: ' . $gcs_uri);
} else {
$gcs_error_data = json_decode($gcs_response, true);
$gcs_error_message = isset($gcs_error_data['error']['message']) ? $gcs_error_data['error']['message'] : '';
error_log('GCS 업로드 실패 (HTTP ' . $gcs_code . '): ' . $gcs_response);
error_log('GCS cURL 오류: ' . $gcs_error);
// GCS 업로드 실패 상세 정보
$gcs_failure_details = 'GCS 업로드 실패 (HTTP ' . $gcs_code . ')';
if ($gcs_error_message) {
$gcs_failure_details .= ': ' . $gcs_error_message;
}
if ($gcs_error) {
$gcs_failure_details .= ' / cURL 오류: ' . $gcs_error;
}
$gcs_failure_details .= ' / 버킷: ' . $bucket_name;
$gcs_failure_details .= ' / 서비스 계정에 Storage 권한이 있는지 확인해주세요.';
sendErrorResponse('Google STT 변환 실패',
'원래 오류: ' . $error_message .
' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' .
' / ' . $gcs_failure_details);
}
} else {
// accessToken이 없는 경우
sendErrorResponse('Google STT 변환 실패',
'원래 오류: ' . $error_message .
' / 파일 크기: ' . round($file_size / 1024 / 1024, 2) . ' MB' .
' / GCS 업로드를 위해 서비스 계정 인증이 필요합니다. accessToken을 생성할 수 없습니다.');
}
// GCS URI 사용하여 재시도
$requestBody['audio'] = ['uri' => $gcs_uri];
// 재시도
$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);
// 타임아웃 설정 (504 오류 방지)
curl_setopt($ch, CURLOPT_TIMEOUT, 50); // 50초
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 연결 타임아웃 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) {
$retry_error_data = json_decode($operation_response, true);
$retry_error_message = isset($retry_error_data['error']['message']) ? $retry_error_data['error']['message'] : '';
error_log('Google STT 재시도 실패 (GCS URI, HTTP ' . $operation_code . '): ' . $retry_error_message);
sendErrorResponse('Google STT 변환 실패 (HTTP ' . $operation_code . ')',
'GCS URI 사용 후에도 실패했습니다. 원래 오류: ' . $error_message .
($retry_error_message ? ' / 재시도 오류: ' . $retry_error_message : '') .
' / GCS URI: ' . $gcs_uri);
}
// 재시도 성공 시 계속 진행
} else {
// 다른 오류인 경우 - 상세 오류 메시지 반환
$full_error = $error_message ?: ($operation_response ? substr($operation_response, 0, 500) : '응답 없음');
if ($operation_error) {
$full_error .= ' / cURL 오류: ' . $operation_error;
}
sendErrorResponse('Google STT 변환 실패 (HTTP ' . $operation_code . ')', $full_error);
}
}
$operation_data = json_decode($operation_response, true);
// $web_path 정의
$web_path = "/uploads/consults/" . $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);
sendErrorResponse('음성 인식 결과가 없습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.');
}
// 텍스트 변환
$transcript = '';
foreach ($operation_data['results'] as $result) {
if (isset($result['alternatives'][0]['transcript'])) {
$transcript .= $result['alternatives'][0]['transcript'] . ' ';
}
}
$transcript = trim($transcript);
if (empty($transcript)) {
sendErrorResponse('인식된 텍스트가 없습니다.', '녹음된 오디오에서 텍스트를 추출할 수 없었습니다.');
}
// 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 연결 및 consult_logs 테이블 확인/생성
$pdo = db_connect();
if (!$pdo) {
sendErrorResponse('데이터베이스 연결 실패');
}
// 테이블 존재 확인 및 AUTO_INCREMENT 처리 (기존 로직 재사용)
try {
$checkTable = $pdo->query("SHOW TABLES LIKE 'consult_logs'");
$tableExists = $checkTable->rowCount() > 0;
if (!$tableExists) {
$createTableSql = "CREATE TABLE IF NOT EXISTS consult_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
audio_file_path VARCHAR(500),
transcript_text TEXT,
summary_text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
file_expiry_date DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$pdo->exec($createTableSql);
error_log('consult_logs 테이블 생성 완료');
}
} catch (PDOException $e) {
error_log('테이블 확인/생성 오류: ' . $e->getMessage());
}
// DB에 저장
$insertSql = "INSERT INTO consult_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]);
$consult_id = $pdo->lastInsertId();
if (!$consult_id) {
sendErrorResponse('데이터베이스 저장 실패', 'INSERT ID를 가져올 수 없습니다.');
}
// 즉시 완료 응답
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => false,
'done' => true,
'consult_id' => $consult_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);
sendErrorResponse('Google STT 작업 시작 실패', substr($operation_response, 0, 500));
}
$operation_name = $operation_data['name'];
// 504 Gateway Timeout 방지를 위해 비동기 처리로 변경
// 작업을 시작하고 즉시 응답, 클라이언트에서 폴링하도록 변경
// 작업 정보를 임시로 DB에 저장
// DB 연결 및 consult_logs 테이블 확인/생성
$pdo_temp = db_connect();
if (!$pdo_temp) {
sendErrorResponse('데이터베이스 연결 실패');
}
// 테이블 존재 확인 및 AUTO_INCREMENT 처리
try {
// 테이블 존재 여부 확인
$checkTable = $pdo_temp->query("SHOW TABLES LIKE 'consult_logs'");
$tableExists = $checkTable->rowCount() > 0;
if (!$tableExists) {
// 테이블 생성
$createTableSql = "CREATE TABLE IF NOT EXISTS consult_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
audio_file_path VARCHAR(500),
transcript_text TEXT,
summary_text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
file_expiry_date DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$pdo_temp->exec($createTableSql);
error_log('consult_logs 테이블 생성 완료');
} else {
// 테이블이 존재하지만 AUTO_INCREMENT가 없는 경우 수정
$checkColumn = $pdo_temp->query("SHOW COLUMNS FROM consult_logs WHERE Field = 'id'");
$columnInfo = $checkColumn->fetch(PDO::FETCH_ASSOC);
if ($columnInfo && strpos($columnInfo['Extra'], 'auto_increment') === false) {
// AUTO_INCREMENT가 없으면 추가
try {
$pdo_temp->exec("ALTER TABLE consult_logs MODIFY id INT AUTO_INCREMENT PRIMARY KEY");
error_log('consult_logs 테이블 AUTO_INCREMENT 추가 완료');
} catch (PDOException $e) {
error_log('AUTO_INCREMENT 추가 실패: ' . $e->getMessage());
// PRIMARY KEY가 이미 있는 경우 MODIFY만 시도
try {
$pdo_temp->exec("ALTER TABLE consult_logs MODIFY id INT AUTO_INCREMENT");
error_log('consult_logs 테이블 AUTO_INCREMENT 수정 완료');
} catch (PDOException $e2) {
error_log('AUTO_INCREMENT 수정 실패: ' . $e2->getMessage());
}
}
}
// id=0 또는 NULL인 레코드 삭제 (PRIMARY KEY 충돌 방지)
$pdo_temp->exec("DELETE FROM consult_logs WHERE id = 0 OR id IS NULL");
// AUTO_INCREMENT 값 확인 및 수정
$checkAutoIncrement = $pdo_temp->query("SHOW TABLE STATUS LIKE 'consult_logs'");
$tableStatus = $checkAutoIncrement->fetch(PDO::FETCH_ASSOC);
if ($tableStatus && ($tableStatus['Auto_increment'] == 0 || $tableStatus['Auto_increment'] == null)) {
// 최대 ID 확인
$maxIdResult = $pdo_temp->query("SELECT MAX(id) as max_id FROM consult_logs");
$maxIdRow = $maxIdResult->fetch(PDO::FETCH_ASSOC);
$maxId = $maxIdRow['max_id'] ? (int)$maxIdRow['max_id'] : 0;
if ($maxId > 0) {
$pdo_temp->exec("ALTER TABLE consult_logs AUTO_INCREMENT = " . ($maxId + 1));
} else {
$pdo_temp->exec("ALTER TABLE consult_logs AUTO_INCREMENT = 1");
}
error_log('consult_logs AUTO_INCREMENT 값 수정: ' . ($maxId + 1));
}
}
} catch (PDOException $e) {
error_log('테이블 확인/생성 오류: ' . $e->getMessage());
// 오류가 있어도 계속 진행 (테이블이 이미 존재할 수 있음)
}
// 임시 레코드 삽입
$temp_sql = "INSERT INTO consult_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_consult_id = $pdo_temp->lastInsertId();
if (!$temp_consult_id) {
// lastInsertId 실패 시 에러 로그 및 스키마 정보 출력
$errorInfo = $pdo_temp->errorInfo();
error_log('lastInsertId 실패 - errorInfo: ' . print_r($errorInfo, true));
// 테이블 스키마 확인
try {
$schemaResult = $pdo_temp->query("SHOW CREATE TABLE consult_logs");
$schemaRow = $schemaResult->fetch(PDO::FETCH_ASSOC);
if ($schemaRow) {
error_log('consult_logs 테이블 스키마: ' . $schemaRow['Create Table']);
}
} catch (PDOException $e) {
error_log('스키마 확인 실패: ' . $e->getMessage());
}
sendErrorResponse('데이터베이스 저장 실패', 'INSERT ID를 가져올 수 없습니다. 테이블의 AUTO_INCREMENT 설정을 확인해주세요.');
}
// 즉시 응답 (클라이언트에서 폴링하도록)
@ob_clean();
@ob_end_clean();
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'processing' => true,
'consult_id' => $temp_consult_id,
'operation_name' => $operation_name,
'message' => '음성 인식 작업이 시작되었습니다. 처리 완료까지 시간이 걸릴 수 있습니다.',
'poll_url' => 'check_consult_status.php?consult_id=' . $temp_consult_id . '&operation_name=' . urlencode($operation_name),
'access_token_available' => !empty($accessToken)
], JSON_UNESCAPED_UNICODE);
exit;
// 비동기 처리: 작업이 시작되었으므로 즉시 응답하고 종료
// 나머지 처리는 check_consult_status.php에서 클라이언트 폴링으로 처리됨
// 이전 동기 처리 코드는 check_consult_status.php로 이동됨
}
?>