374 lines
14 KiB
PHP
374 lines
14 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);
|
||
|
|
|
||
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
||
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
||
|
|
|
||
|
|
// 에러 응답 함수
|
||
|
|
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. 권한 체크
|
||
|
|
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);
|
||
|
|
?>
|
||
|
|
|