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. 권한 체크 if (!isset($user_id) || $level > 5) { sendErrorResponse('접근 권한이 없습니다.'); } // 2. 파라미터 확인 $consult_id = isset($_GET['consult_id']) ? (int)$_GET['consult_id'] : 0; $operation_name = isset($_GET['operation_name']) ? trim($_GET['operation_name']) : ''; if (!$consult_id || !$operation_name) { sendErrorResponse('필수 파라미터가 없습니다.', 'consult_id와 operation_name이 필요합니다.'); } // 3. Google API 인증 정보 가져오기 $googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json'; $googleApiKeyFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_api.txt'; $accessToken = null; $googleApiKey = null; // 서비스 계정 우선 사용 if (file_exists($googleServiceAccountFile)) { $serviceAccount = json_decode(file_get_contents($googleServiceAccountFile), true); if ($serviceAccount) { // OAuth 2.0 토큰 생성 $now = time(); $jwtHeader = ['alg' => 'RS256', 'typ' => '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 ]; $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) { $signature = ''; $signData = $encodedHeader . '.' . $encodedClaim; if (openssl_sign($signData, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { openssl_free_key($privateKey); $encodedSignature = $base64UrlEncode($signature); $jwt = $encodedHeader . '.' . $encodedClaim . '.' . $encodedSignature; // 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']); // 타임아웃 설정 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); curl_close($tokenCh); if ($tokenCode === 200) { $tokenData = json_decode($tokenResponse, true); if (isset($tokenData['access_token'])) { $accessToken = $tokenData['access_token']; } } } } } } // API 키 사용 if (!$accessToken && file_exists($googleApiKeyFile)) { $googleApiKey = trim(file_get_contents($googleApiKeyFile)); } if (!$accessToken && !$googleApiKey) { sendErrorResponse('Google API 인증 정보가 없습니다.'); } // 4. Google API에서 작업 상태 확인 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); } curl_setopt($poll_ch, CURLOPT_TIMEOUT, 30); $poll_response = curl_exec($poll_ch); $poll_code = curl_getinfo($poll_ch, CURLINFO_HTTP_CODE); $poll_error = curl_error($poll_ch); curl_close($poll_ch); if ($poll_code !== 200) { sendErrorResponse('작업 상태 확인 실패 (HTTP ' . $poll_code . ')', $poll_error ?: substr($poll_response, 0, 500)); } $poll_data = json_decode($poll_response, true); if (!$poll_data) { sendErrorResponse('작업 상태 응답 파싱 실패', substr($poll_response, 0, 500)); } // 5. 작업 완료 여부 확인 if (!isset($poll_data['done']) || $poll_data['done'] !== true) { // 아직 처리 중 echo json_encode([ 'ok' => true, 'processing' => true, 'done' => false, 'message' => '음성 인식 처리 중입니다. 잠시 후 다시 확인해주세요.' ], JSON_UNESCAPED_UNICODE); exit; } // 6. 작업 완료 - 결과 처리 if (isset($poll_data['error'])) { // 오류 발생 $pdo = db_connect(); $errorMsg = isset($poll_data['error']['message']) ? $poll_data['error']['message'] : '알 수 없는 오류'; $updateSql = "UPDATE consult_logs SET title = ?, summary_text = ? WHERE id = ?"; $updateStmt = $pdo->prepare($updateSql); $updateStmt->execute(['오류 발생', 'Google STT 변환 실패: ' . $errorMsg, $consult_id]); echo json_encode([ 'ok' => false, 'error' => 'Google STT 변환 실패', 'details' => $errorMsg ], JSON_UNESCAPED_UNICODE); exit; } // 응답 구조 확인 및 로깅 error_log('Google STT 응답 구조: ' . json_encode([ 'has_response' => isset($poll_data['response']), 'has_results' => isset($poll_data['response']['results']), 'results_count' => isset($poll_data['response']['results']) ? count($poll_data['response']['results']) : 0, 'response_keys' => isset($poll_data['response']) ? array_keys($poll_data['response']) : [], 'poll_data_keys' => array_keys($poll_data) ], JSON_UNESCAPED_UNICODE)); // response 필드가 없는 경우 if (!isset($poll_data['response'])) { error_log('Google STT 응답에 response 필드가 없습니다. 전체 응답: ' . json_encode($poll_data, JSON_UNESCAPED_UNICODE)); sendErrorResponse('Google STT 응답 구조 오류', '응답에 response 필드가 없습니다. 작업이 완료되었지만 결과를 가져올 수 없습니다.'); } // results가 없는 경우 if (!isset($poll_data['response']['results'])) { error_log('Google STT 응답에 results 필드가 없습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE)); sendErrorResponse('Google STT 응답에 결과가 없습니다.', '응답에 results 필드가 없습니다. 음성이 인식되지 않았을 수 있습니다.'); } // results가 비어있는 경우 if (empty($poll_data['response']['results'])) { error_log('Google STT 응답에 results가 비어있습니다. response 내용: ' . json_encode($poll_data['response'], JSON_UNESCAPED_UNICODE)); // 빈 결과를 DB에 저장하고 사용자에게 알림 $pdo = db_connect(); $updateSql = "UPDATE consult_logs SET title = ?, transcript_text = ?, summary_text = ? WHERE id = ?"; $updateStmt = $pdo->prepare($updateSql); $updateStmt->execute(['음성 인식 실패', '음성이 인식되지 않았습니다.', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.', $consult_id]); sendErrorResponse('음성 인식 실패', '녹음된 오디오에서 음성을 인식할 수 없었습니다. 마이크 권한과 오디오 품질을 확인해주세요.'); } // 7. 텍스트 변환 $stt_data = ['results' => $poll_data['response']['results']]; $transcript = ''; foreach ($stt_data['results'] as $result) { if (isset($result['alternatives'][0]['transcript'])) { $transcript .= $result['alternatives'][0]['transcript'] . ' '; } } $transcript = trim($transcript); if (empty($transcript)) { sendErrorResponse('인식된 텍스트가 없습니다.'); } // 8. 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); $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 키가 설정되지 않아 요약을 생성할 수 없습니다. (원문 저장됨)"; } // 9. DB 업데이트 $pdo = db_connect(); $updateSql = "UPDATE consult_logs SET title = ?, transcript_text = ?, summary_text = ? WHERE id = ?"; $updateStmt = $pdo->prepare($updateSql); $updateStmt->execute([$title, $transcript, $summary, $consult_id]); // 10. 완료 응답 @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); ?>