feat:[sales] AI 대화형 인터뷰 서비스 생성 (AiInterviewService)
This commit is contained in:
738
app/Services/Sales/AiInterviewService.php
Normal file
738
app/Services/Sales/AiInterviewService.php
Normal file
@@ -0,0 +1,738 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Models\Interview\InterviewAiConversation;
|
||||
use App\Models\Interview\InterviewAnswer;
|
||||
use App\Models\Interview\InterviewCategory;
|
||||
use App\Models\Interview\InterviewKnowledge;
|
||||
use App\Models\Interview\InterviewQuestion;
|
||||
use App\Models\Interview\InterviewSession;
|
||||
use App\Models\Interview\InterviewTemplate;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiInterviewService
|
||||
{
|
||||
// ============================================================
|
||||
// AI 세션 시작
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* AI 가이드 인터뷰 세션 시작
|
||||
*
|
||||
* @param int $projectId 프로젝트 ID
|
||||
* @param string $domain 인터뷰 도메인 (예: 'production', 'inventory')
|
||||
* @return array session 정보 + AI 첫 메시지
|
||||
*/
|
||||
public function startAiSession(int $projectId, string $domain): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
// 해당 도메인의 카테고리와 질문 로드
|
||||
$category = InterviewCategory::where('interview_project_id', $projectId)
|
||||
->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 <<<PROMPT
|
||||
당신은 제조업 ERP/MES 시스템 구축을 위한 전문 인터뷰어입니다.
|
||||
|
||||
현재 도메인: {$domainName}
|
||||
수집 목표: {$domainDescription}
|
||||
|
||||
아래는 이 도메인에서 수집해야 할 핵심 질문 목록입니다:
|
||||
{$questionList}
|
||||
|
||||
지침:
|
||||
1. 자연스러운 대화체로 한 번에 1-2개 질문을 합니다
|
||||
2. 답변이 불충분하면 구체적인 예시를 요청합니다
|
||||
3. 답변에서 구조화 가능한 데이터를 발견하면 JSON으로 추출합니다
|
||||
4. 모든 핵심 질문이 커버될 때까지 대화를 이어갑니다
|
||||
5. 응답 마지막에 반드시 아래 형식의 JSON 블록을 포함합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"extracted_data": {},
|
||||
"covered_questions": [],
|
||||
"coverage_percent": 0,
|
||||
"next_focus": "다음에 물어볼 영역"
|
||||
}
|
||||
```
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지식 추출 프롬프트 생성
|
||||
*/
|
||||
private function buildKnowledgeExtractionPrompt(string $conversationText): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
다음은 제조업 ERP/MES 구축을 위한 고객 인터뷰 대화 내용입니다.
|
||||
이 대화에서 구조화된 지식 항목을 추출해주세요.
|
||||
|
||||
[대화 내용]
|
||||
{$conversationText}
|
||||
|
||||
각 지식 항목은 다음 형식으로 추출하세요:
|
||||
- domain: 해당 도메인 (예: production, inventory, sales, quality, hr, finance, general)
|
||||
- knowledge_type: 지식 유형 (process, rule, constraint, requirement, custom)
|
||||
- title: 지식 제목 (간결하게)
|
||||
- content: 상세 내용 (JSON 객체, key-value 구조)
|
||||
- confidence: 신뢰도 (0.0~1.0)
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만 반환하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"extracted_data": {
|
||||
"knowledge_list": [
|
||||
{
|
||||
"domain": "production",
|
||||
"knowledge_type": "process",
|
||||
"title": "생산 계획 수립 주기",
|
||||
"content": {
|
||||
"summary": "주 단위로 생산 계획 수립",
|
||||
"details": "매주 월요일 생산팀 회의에서 확정"
|
||||
},
|
||||
"confidence": 0.90
|
||||
}
|
||||
]
|
||||
},
|
||||
"covered_questions": [],
|
||||
"coverage_percent": 100,
|
||||
"next_focus": "완료"
|
||||
}
|
||||
```
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private: 대화 히스토리 구성
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Gemini 멀티턴 대화를 위한 contents 배열 구성
|
||||
*/
|
||||
private function buildConversationHistory(int $sessionId): array
|
||||
{
|
||||
$conversations = InterviewAiConversation::where('interview_session_id', $sessionId)
|
||||
->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user