From fdd69d44c69c03fc90ca41562e809fae35d2cf78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 22 Mar 2026 22:38:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:[sales]=20AI=20=EB=8C=80=ED=99=94=ED=98=95?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=EB=B7=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(AiInterviewService)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/Sales/AiInterviewService.php | 738 ++++++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 app/Services/Sales/AiInterviewService.php diff --git a/app/Services/Sales/AiInterviewService.php b/app/Services/Sales/AiInterviewService.php new file mode 100644 index 00000000..acb82232 --- /dev/null +++ b/app/Services/Sales/AiInterviewService.php @@ -0,0 +1,738 @@ +where('domain', $domain) + ->with(['templates' => function ($q) { + $q->where('is_active', true) + ->orderBy('sort_order') + ->with(['questions' => function ($q2) { + $q2->where('is_active', true)->orderBy('sort_order'); + }]); + }]) + ->first(); + + if (! $category) { + // 도메인 매칭 카테고리가 없으면 이름으로 검색 + $category = InterviewCategory::where('interview_project_id', $projectId) + ->where('name', 'like', "%{$domain}%") + ->with(['templates' => function ($q) { + $q->where('is_active', true) + ->orderBy('sort_order') + ->with(['questions' => function ($q2) { + $q2->where('is_active', true)->orderBy('sort_order'); + }]); + }]) + ->first(); + } + + // 카테고리의 총 질문 수 계산 + $totalQuestions = 0; + $allQuestions = []; + if ($category) { + foreach ($category->templates as $template) { + foreach ($template->questions as $question) { + $allQuestions[] = $question; + $totalQuestions++; + } + } + } + + // AI 가이드 세션 생성 + $session = DB::transaction(function () use ($tenantId, $projectId, $category, $totalQuestions) { + return InterviewSession::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => $projectId, + 'interview_category_id' => $category?->id, + 'interviewer_id' => Auth::id(), + 'interview_date' => now()->toDateString(), + 'status' => 'in_progress', + 'session_type' => 'ai_guided', + 'total_questions' => $totalQuestions, + 'answered_questions' => 0, + 'created_by' => Auth::id(), + 'updated_by' => Auth::id(), + ]); + }); + + // 시스템 프롬프트 생성 및 Gemini 호출 + $systemPrompt = $this->buildSystemPrompt($domain, $category, $allQuestions); + $firstPrompt = $systemPrompt."\n\n지금 인터뷰를 시작해주세요. 자기소개와 함께 첫 번째 질문을 해주세요."; + + $result = $this->callGemini([ + ['role' => 'user', 'parts' => [['text' => $firstPrompt]]], + ]); + + $aiMessage = $result['text'] ?? '안녕하세요! 인터뷰를 시작하겠습니다.'; + $structuredData = $result['structured_data'] ?? null; + $tokensUsed = $result['tokens_used'] ?? 0; + $modelUsed = $result['model_used'] ?? ''; + + // 시스템 프롬프트 저장 (role: system) + InterviewAiConversation::create([ + 'tenant_id' => $tenantId, + 'interview_session_id' => $session->id, + 'interview_project_id' => $projectId, + 'role' => 'system', + 'content' => $systemPrompt, + 'domain' => $domain, + 'tokens_used' => 0, + 'model_used' => $modelUsed, + ]); + + // AI 첫 응답 저장 + InterviewAiConversation::create([ + 'tenant_id' => $tenantId, + 'interview_session_id' => $session->id, + 'interview_project_id' => $projectId, + 'role' => 'assistant', + 'content' => $aiMessage, + 'structured_data' => $structuredData, + 'domain' => $domain, + 'tokens_used' => $tokensUsed, + 'model_used' => $modelUsed, + ]); + + return [ + 'session' => $session->fresh(), + 'ai_message' => $aiMessage, + 'structured_data' => $structuredData, + 'domain' => $domain, + 'total_questions' => $totalQuestions, + ]; + } + + // ============================================================ + // 메시지 전송 및 AI 응답 + // ============================================================ + + /** + * 사용자 메시지 전송 및 AI 응답 수신 + * + * @param int $sessionId 세션 ID + * @param string $message 사용자 메시지 + * @param string|null $domain 도메인 (null이면 세션에서 조회) + * @return array AI 응답 + 추출된 데이터 + 진행률 + */ + public function sendMessage(int $sessionId, string $message, ?string $domain = null): array + { + $tenantId = session('selected_tenant_id', 1); + $session = InterviewSession::findOrFail($sessionId); + $projectId = $session->interview_project_id; + + // 도메인 결정 + if (! $domain) { + $lastConv = InterviewAiConversation::where('interview_session_id', $sessionId) + ->whereNotNull('domain') + ->latest('id') + ->first(); + $domain = $lastConv?->domain ?? ''; + } + + // 사용자 메시지 저장 + InterviewAiConversation::create([ + 'tenant_id' => $tenantId, + 'interview_session_id' => $sessionId, + 'interview_project_id' => $projectId, + 'role' => 'user', + 'content' => $message, + 'domain' => $domain, + 'tokens_used' => 0, + 'model_used' => null, + ]); + + // 이전 대화 히스토리 로드 (system 제외, user/assistant만) + $history = $this->buildConversationHistory($sessionId); + + // Gemini API 호출 + $result = $this->callGemini($history); + + $aiMessage = $result['text'] ?? ''; + $structuredData = $result['structured_data'] ?? null; + $coveredQuestions = $result['covered_questions'] ?? []; + $coveragePercent = $result['coverage_percent'] ?? 0; + $tokensUsed = $result['tokens_used'] ?? 0; + $modelUsed = $result['model_used'] ?? ''; + + // AI 응답 저장 + InterviewAiConversation::create([ + 'tenant_id' => $tenantId, + 'interview_session_id' => $sessionId, + 'interview_project_id' => $projectId, + 'role' => 'assistant', + 'content' => $aiMessage, + 'structured_data' => $structuredData, + 'domain' => $domain, + 'tokens_used' => $tokensUsed, + 'model_used' => $modelUsed, + ]); + + // 구조화된 답변 자동 저장 + if (! empty($extractedData = $result['extracted_data'] ?? null)) { + $this->saveExtractedAnswers($session, $domain, $extractedData, $coveredQuestions); + } + + // 세션 진행률 갱신 + $answeredCount = InterviewAnswer::where('interview_session_id', $sessionId) + ->where('is_checked', true) + ->count(); + $session->update([ + 'answered_questions' => $answeredCount, + 'updated_by' => Auth::id(), + ]); + + return [ + 'ai_message' => $aiMessage, + 'extracted_data' => $structuredData, + 'covered_questions' => $coveredQuestions, + 'coverage_percent' => $coveragePercent, + 'answered_questions' => $answeredCount, + 'total_questions' => $session->total_questions, + ]; + } + + // ============================================================ + // 대화 히스토리 조회 + // ============================================================ + + /** + * 세션의 전체 대화 기록 조회 + * + * @param int $sessionId 세션 ID + * @return array 대화 기록 목록 + */ + public function getConversationHistory(int $sessionId): array + { + $conversations = InterviewAiConversation::where('interview_session_id', $sessionId) + ->orderBy('id') + ->get(); + + return $conversations->map(function ($conv) { + return [ + 'id' => $conv->id, + 'role' => $conv->role, + 'content' => $conv->content, + 'structured_data' => $conv->structured_data, + 'domain' => $conv->domain, + 'tokens_used' => $conv->tokens_used, + 'model_used' => $conv->model_used, + 'created_at' => $conv->created_at?->toIso8601String(), + ]; + })->toArray(); + } + + // ============================================================ + // 진행률 분석 + // ============================================================ + + /** + * 도메인별 질문 커버리지 분석 + * + * @param int $sessionId 세션 ID + * @return array 도메인별 커버리지 + 누락 질문 + 완료도 백분율 + */ + public function analyzeProgress(int $sessionId): array + { + $session = InterviewSession::findOrFail($sessionId); + $projectId = $session->interview_project_id; + + // 프로젝트의 모든 카테고리/질문 로드 + $categories = InterviewCategory::where('interview_project_id', $projectId) + ->with(['templates' => function ($q) { + $q->where('is_active', true) + ->with(['questions' => function ($q2) { + $q2->where('is_active', true)->orderBy('sort_order'); + }]); + }]) + ->get(); + + // 세션의 체크된 답변 질문 ID 목록 + $answeredQuestionIds = InterviewAnswer::where('interview_session_id', $sessionId) + ->where('is_checked', true) + ->pluck('interview_question_id') + ->toArray(); + + // 대화에서 언급된 도메인 목록 + $coveredDomains = InterviewAiConversation::where('interview_session_id', $sessionId) + ->whereNotNull('domain') + ->distinct() + ->pluck('domain') + ->toArray(); + + $domainAnalysis = []; + $totalQuestions = 0; + $totalAnswered = 0; + $missingQuestions = []; + + foreach ($categories as $category) { + $categoryQuestions = []; + $categoryAnswered = 0; + + foreach ($category->templates as $template) { + foreach ($template->questions as $question) { + $isAnswered = in_array($question->id, $answeredQuestionIds); + $categoryQuestions[] = [ + 'id' => $question->id, + 'text' => $question->question_text, + 'is_required' => $question->is_required, + 'is_answered' => $isAnswered, + ]; + + if ($isAnswered) { + $categoryAnswered++; + } elseif ($question->is_required) { + $missingQuestions[] = [ + 'category' => $category->name, + 'domain' => $category->domain, + 'question' => $question->question_text, + 'template' => $template->name, + ]; + } + } + } + + $categoryTotal = count($categoryQuestions); + $totalQuestions += $categoryTotal; + $totalAnswered += $categoryAnswered; + + if ($categoryTotal > 0) { + $domainAnalysis[] = [ + 'category_id' => $category->id, + 'category_name' => $category->name, + 'domain' => $category->domain, + 'total_questions' => $categoryTotal, + 'answered_questions' => $categoryAnswered, + 'coverage_percent' => (int) round(($categoryAnswered / $categoryTotal) * 100), + 'is_covered_in_conversation' => in_array($category->domain, $coveredDomains), + 'questions' => $categoryQuestions, + ]; + } + } + + $overallPercent = $totalQuestions > 0 + ? (int) round(($totalAnswered / $totalQuestions) * 100) + : 0; + + return [ + 'overall_coverage_percent' => $overallPercent, + 'total_questions' => $totalQuestions, + 'total_answered' => $totalAnswered, + 'domain_analysis' => $domainAnalysis, + 'missing_required_questions' => $missingQuestions, + ]; + } + + // ============================================================ + // 지식 추출 + // ============================================================ + + /** + * 전체 대화에서 지식 추출 후 interview_knowledge 저장 + * + * @param int $sessionId 세션 ID + * @return array 추출된 지식 목록 + */ + public function extractKnowledge(int $sessionId): array + { + $session = InterviewSession::findOrFail($sessionId); + $projectId = $session->interview_project_id; + $tenantId = session('selected_tenant_id', 1); + + // 전체 대화 히스토리 (user + assistant만) + $conversations = InterviewAiConversation::where('interview_session_id', $sessionId) + ->whereIn('role', ['user', 'assistant']) + ->orderBy('id') + ->get(); + + if ($conversations->isEmpty()) { + return ['knowledge' => [], 'count' => 0]; + } + + // 대화 텍스트 구성 + $conversationText = $conversations->map(function ($conv) { + $roleLabel = $conv->role === 'user' ? '고객' : 'AI'; + + return "[{$roleLabel}]: {$conv->content}"; + })->implode("\n\n"); + + // 지식 추출 프롬프트 + $extractPrompt = $this->buildKnowledgeExtractionPrompt($conversationText); + + $result = $this->callGemini([ + ['role' => 'user', 'parts' => [['text' => $extractPrompt]]], + ]); + + $knowledgeList = $result['extracted_data']['knowledge_list'] ?? []; + $savedKnowledge = []; + + foreach ($knowledgeList as $item) { + try { + $knowledge = InterviewKnowledge::create([ + 'tenant_id' => $tenantId, + 'interview_project_id' => $projectId, + 'domain' => $item['domain'] ?? 'general', + 'knowledge_type' => $item['knowledge_type'] ?? 'process', + 'title' => $item['title'] ?? '미분류 지식', + 'content' => $item['content'] ?? [], + 'source_type' => 'ai_interview', + 'source_id' => $sessionId, + 'confidence' => $item['confidence'] ?? 0.80, + 'is_verified' => false, + 'created_by' => Auth::id(), + ]); + + $savedKnowledge[] = $knowledge; + } catch (\Exception $e) { + Log::warning('지식 저장 실패', [ + 'session_id' => $sessionId, + 'item' => $item, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'knowledge' => $savedKnowledge, + 'count' => count($savedKnowledge), + ]; + } + + // ============================================================ + // Private: Gemini API 호출 + // ============================================================ + + /** + * Gemini API 호출 (대화형 멀티턴) + * + * @param array $contents 대화 contents 배열 (role + parts 구조) + * @return array text, structured_data, extracted_data, covered_questions, coverage_percent, tokens_used, model_used + */ + private function callGemini(array $contents): array + { + $apiKey = config('services.gemini.api_key'); + $model = config('services.gemini.model', 'gemini-2.5-flash'); + $baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta'); + + $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; + + try { + $response = Http::timeout(120) + ->withHeaders(['Content-Type' => 'application/json']) + ->post($url, [ + 'contents' => $contents, + 'generationConfig' => [ + 'temperature' => 0.7, + 'maxOutputTokens' => 8192, + ], + ]); + + if (! $response->successful()) { + Log::error('Gemini API 오류', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + throw new \RuntimeException('Gemini API 호출 실패: '.$response->status()); + } + + $apiResult = $response->json(); + + // 토큰 사용량 기록 + AiTokenHelper::saveGeminiUsage( + $apiResult, + $apiResult['modelVersion'] ?? $model, + 'AI인터뷰' + ); + + $rawText = $apiResult['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $tokensUsed = $apiResult['usageMetadata']['totalTokenCount'] ?? 0; + $modelUsed = $apiResult['modelVersion'] ?? $model; + + // JSON 블록 파싱 (```json ... ``` 사이) + $structuredData = $this->parseJsonBlock($rawText); + + // AI 응답 텍스트에서 JSON 블록 제거 + $cleanText = preg_replace('/```json[\s\S]*?```/i', '', $rawText); + $cleanText = trim($cleanText); + + return [ + 'text' => $cleanText ?: $rawText, + 'structured_data' => $structuredData, + 'extracted_data' => $structuredData['extracted_data'] ?? null, + 'covered_questions' => $structuredData['covered_questions'] ?? [], + 'coverage_percent' => $structuredData['coverage_percent'] ?? 0, + 'tokens_used' => $tokensUsed, + 'model_used' => $modelUsed, + ]; + } catch (\Exception $e) { + Log::error('Gemini 호출 예외', [ + 'error' => $e->getMessage(), + ]); + + return [ + 'text' => 'AI 응답 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + 'structured_data' => null, + 'extracted_data' => null, + 'covered_questions' => [], + 'coverage_percent' => 0, + 'tokens_used' => 0, + 'model_used' => $model, + ]; + } + } + + // ============================================================ + // Private: 프롬프트 생성 + // ============================================================ + + /** + * 시스템 프롬프트 생성 + */ + private function buildSystemPrompt(string $domain, ?InterviewCategory $category, array $questions): string + { + $domainName = $category?->name ?? $domain; + $domainDescription = $category?->description ?? "{$domain} 도메인 관련 업무 파악"; + + $questionList = ''; + foreach ($questions as $index => $question) { + $hint = $question->ai_hint ? " (힌트: {$question->ai_hint})" : ''; + $required = $question->is_required ? ' [필수]' : ''; + $type = $question->question_type ?? 'text'; + $questionList .= ($index + 1).". [{$type}]{$required} {$question->question_text}{$hint}\n"; + } + + if (empty($questionList)) { + $questionList = "- 해당 도메인의 전반적인 업무 프로세스와 요구사항을 파악합니다.\n"; + } + + return <<whereIn('role', ['system', 'user', 'assistant']) + ->orderBy('id') + ->get(); + + $contents = []; + + foreach ($conversations as $conv) { + if ($conv->role === 'system') { + // 시스템 프롬프트는 첫 user 메시지에 합산 + $systemText = $conv->content; + $contents[] = [ + 'role' => 'user', + 'parts' => [['text' => $systemText]], + ]; + // 시스템 다음에 assistant 더미 응답 삽입 (Gemini 멀티턴 규칙: user/model 교대) + $contents[] = [ + 'role' => 'model', + 'parts' => [['text' => '네, 이해했습니다. 인터뷰를 시작하겠습니다.']], + ]; + } elseif ($conv->role === 'user') { + $contents[] = [ + 'role' => 'user', + 'parts' => [['text' => $conv->content]], + ]; + } elseif ($conv->role === 'assistant') { + $contents[] = [ + 'role' => 'model', + 'parts' => [['text' => $conv->content]], + ]; + } + } + + return $contents; + } + + // ============================================================ + // Private: JSON 블록 파싱 + // ============================================================ + + /** + * 응답 텍스트에서 ```json ... ``` 블록 파싱 + */ + private function parseJsonBlock(string $text): ?array + { + if (preg_match('/```json\s*([\s\S]*?)\s*```/i', $text, $matches)) { + $jsonString = trim($matches[1]); + $decoded = json_decode($jsonString, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + Log::warning('AI 응답 JSON 파싱 실패', [ + 'json_error' => json_last_error_msg(), + 'raw' => $jsonString, + ]); + } + + return null; + } + + // ============================================================ + // Private: 추출된 답변 자동 저장 + // ============================================================ + + /** + * AI가 추출한 데이터를 interview_answers에 자동 저장 + */ + private function saveExtractedAnswers( + InterviewSession $session, + string $domain, + array $extractedData, + array $coveredQuestionIndexes + ): void { + if (empty($coveredQuestionIndexes) || ! $session->interview_category_id) { + return; + } + + // 도메인 카테고리의 질문 목록 + $questions = InterviewQuestion::whereHas('template', function ($q) use ($session) { + $q->whereHas('category', function ($q2) use ($session) { + $q2->where('id', $session->interview_category_id); + }); + }) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + + foreach ($coveredQuestionIndexes as $questionIndex) { + $idx = (int) $questionIndex - 1; // 1-based → 0-based + if (! isset($questions[$idx])) { + continue; + } + + $question = $questions[$idx]; + + // 기존 답변 레코드 조회 또는 생성 + $answer = InterviewAnswer::where('interview_session_id', $session->id) + ->where('interview_question_id', $question->id) + ->first(); + + if ($answer) { + $answer->update([ + 'is_checked' => true, + 'answer_data' => $extractedData, + ]); + } else { + InterviewAnswer::create([ + 'tenant_id' => $session->tenant_id, + 'interview_session_id' => $session->id, + 'interview_question_id' => $question->id, + 'interview_template_id' => $question->interview_template_id, + 'is_checked' => true, + 'answer_data' => $extractedData, + ]); + } + } + } +}